mirror of
https://github.com/jwinterm/tubbymemes.git
synced 2026-10-17 05:21:28 +02:00
wire up metamask authentication
This commit is contained in:
@@ -4,6 +4,7 @@ Flask
|
||||
Flask-WTF
|
||||
Flask-SQLAlchemy
|
||||
Flask-Mobility
|
||||
Flask-Login
|
||||
peewee
|
||||
gunicorn
|
||||
huey
|
||||
@@ -13,3 +14,4 @@ ipfs-api
|
||||
python-dotenv
|
||||
requests
|
||||
web3
|
||||
flake8
|
||||
|
||||
+17
-2
@@ -1,13 +1,16 @@
|
||||
from logging.config import dictConfig
|
||||
|
||||
from flask import Flask
|
||||
from flask_login import LoginManager
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_mobility import Mobility
|
||||
from web3 import Web3
|
||||
|
||||
from suchwowx import config
|
||||
|
||||
|
||||
db = SQLAlchemy()
|
||||
w3 = Web3(Web3.HTTPProvider('http://127.0.0.1:9650')) # todo
|
||||
|
||||
|
||||
def setup_db(app: Flask, db:SQLAlchemy=db):
|
||||
@@ -28,10 +31,22 @@ def create_app():
|
||||
app.config.from_envvar('FLASK_SECRETS')
|
||||
setup_db(app)
|
||||
Mobility(app)
|
||||
login_manager = LoginManager(app)
|
||||
login_manager.login_view = 'meme.index'
|
||||
login_manager.logout_view = 'meta.disconnect'
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
from suchwowx.models import User
|
||||
user = User.query.get(user_id)
|
||||
return user
|
||||
|
||||
with app.app_context():
|
||||
from suchwowx import filters, routes, cli
|
||||
from suchwowx import filters, cli
|
||||
from suchwowx.routes import api, meme, meta
|
||||
app.register_blueprint(filters.bp)
|
||||
app.register_blueprint(routes.bp)
|
||||
app.register_blueprint(cli.bp)
|
||||
app.register_blueprint(api.bp)
|
||||
app.register_blueprint(meme.bp)
|
||||
app.register_blueprint(meta.bp)
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from eth_account.messages import encode_defunct
|
||||
|
||||
from suchwowx.factory import w3
|
||||
|
||||
|
||||
def verify_signature(message, signature, public_address):
|
||||
msg = encode_defunct(text=message)
|
||||
recovered = w3.eth.account.recover_message(msg, signature=signature)
|
||||
print(f'found recovered: {recovered}')
|
||||
if recovered.lower() == public_address.lower():
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
+51
-1
@@ -1,6 +1,8 @@
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
|
||||
from flask_login import login_user
|
||||
|
||||
from suchwowx.factory import db
|
||||
from suchwowx import config
|
||||
|
||||
@@ -8,13 +10,61 @@ from suchwowx import config
|
||||
def rand_id():
|
||||
return uuid4().hex
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
register_date = db.Column(db.DateTime, default=datetime.utcnow())
|
||||
last_login_date = db.Column(db.DateTime, nullable=True)
|
||||
verified = db.Column(db.Boolean, default=False)
|
||||
public_address = db.Column(db.String(180))
|
||||
nonce = db.Column(db.String(180), default=rand_id())
|
||||
nonce_date = db.Column(db.DateTime, default=datetime.utcnow())
|
||||
handle = db.Column(db.String(40), unique=True, nullable=True)
|
||||
bio = db.Column(db.String(600), nullable=True)
|
||||
profile_image = db.Column(db.String(300), nullable=True)
|
||||
website_url = db.Column(db.String(120), nullable=True)
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_anonymous(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_admin(self):
|
||||
return self.admin
|
||||
|
||||
def get_id(self):
|
||||
return self.id
|
||||
|
||||
def generate_nonce(self):
|
||||
return rand_id()
|
||||
|
||||
def change_nonce(self):
|
||||
self.nonce = rand_id()
|
||||
self.nonce_date = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def login(self):
|
||||
self.change_nonce()
|
||||
self.last_login_date = datetime.utcnow()
|
||||
login_user(self)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class Meme(db.Model):
|
||||
__tablename__ = 'memes'
|
||||
|
||||
id = db.Column(db.String(80), default=rand_id, primary_key=True)
|
||||
create_date = db.Column(db.DateTime, default=datetime.utcnow())
|
||||
upload_path = db.Column(db.String(200), unique=True)
|
||||
file_name = db.Column(db.String(200), unique=True)
|
||||
meta_ipfs_hash = db.Column(db.String(100), unique=True)
|
||||
meme_ipfs_hash = db.Column(db.String(100), unique=True)
|
||||
title = db.Column(db.String(50))
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from flask import Blueprint, request, jsonify, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
from suchwowx.factory import db
|
||||
from suchwowx.helpers import verify_signature
|
||||
from suchwowx.models import User
|
||||
|
||||
|
||||
bp = Blueprint('api', 'api', url_prefix='/api/v1')
|
||||
|
||||
@bp.route('/user_exists')
|
||||
def user_exists():
|
||||
"""
|
||||
Check to see if a given user exists (handle or wallet address).
|
||||
This logic will help the login/connect MetaMask flow.
|
||||
"""
|
||||
if 'public_address' in request.args:
|
||||
query_str = 'public_address'
|
||||
query_field = User.public_address
|
||||
elif 'handle' in request.args:
|
||||
query_str = 'handle'
|
||||
query_field = User.handle
|
||||
else:
|
||||
return jsonify({'success': False})
|
||||
|
||||
u = User.query.filter(
|
||||
query_field == request.args[query_str].lower()
|
||||
).first()
|
||||
if u:
|
||||
nonce = u.nonce
|
||||
else:
|
||||
nonce = User().generate_nonce()
|
||||
return jsonify({
|
||||
'user_exists': u is not None,
|
||||
'nonce': nonce,
|
||||
'query': query_str,
|
||||
'success': True
|
||||
})
|
||||
|
||||
@bp.route('/authenticate/metamask', methods=['POST'])
|
||||
def authenticate_metamask():
|
||||
"""
|
||||
This is the login/authenticate route for this dApp.
|
||||
Users POST a `signedData` blob, a message signed by the user with MetaMask
|
||||
(`personal_sign` method).
|
||||
|
||||
This route will verify the signed data against the user's public ETH
|
||||
address. If no user exists, they get an entry in the database with a
|
||||
default handle assigned. If user does exist, they get logged in.
|
||||
"""
|
||||
data = request.get_json()
|
||||
if current_user.is_authenticated:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Already registered and authenticated.'
|
||||
})
|
||||
|
||||
_u = User.query.filter_by(
|
||||
public_address=data['public_address'].lower()
|
||||
).first()
|
||||
|
||||
if _u:
|
||||
if data['message'].endswith(_u.nonce):
|
||||
if verify_signature(data['message'], data['signed_data'], data['public_address']):
|
||||
_u.login()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Logged in'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Invalid signature'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Invalid nonce in signed message'
|
||||
})
|
||||
else:
|
||||
rand_str = token_urlsafe(6)
|
||||
user = User(
|
||||
public_address=data['public_address'].lower()
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
user.handle = f'anon{user.id}-{rand_str}'
|
||||
db.session.commit()
|
||||
user.login()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Registered'
|
||||
})
|
||||
@@ -4,16 +4,17 @@ from json import loads, dumps
|
||||
|
||||
import ipfsApi
|
||||
from flask import Blueprint, render_template, request, current_app
|
||||
from flask import send_from_directory, redirect, flash, url_for
|
||||
from flask import send_from_directory, redirect, flash, url_for, jsonify
|
||||
from flask_login import logout_user, current_user, login_user
|
||||
from requests.exceptions import HTTPError
|
||||
from web3 import Web3
|
||||
|
||||
from suchwowx.models import Meme
|
||||
from suchwowx.models import Meme, User
|
||||
from suchwowx.factory import db
|
||||
from suchwowx import config
|
||||
|
||||
|
||||
bp = Blueprint('meta', 'meta')
|
||||
bp = Blueprint('meme', 'meme')
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
@@ -28,12 +29,11 @@ def index():
|
||||
# total_supply = contract.functions.totalSupply().call()
|
||||
return render_template('index.html', memes=memes, contract=contract)
|
||||
|
||||
@bp.route('/about')
|
||||
def about():
|
||||
return render_template('about.html')
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
def new():
|
||||
@bp.route('/publish', methods=['GET', 'POST'])
|
||||
def publish():
|
||||
if not current_user.is_authenticated:
|
||||
flash('You need to connect your wallet first.', 'warning')
|
||||
return redirect(url_for('meme.index'))
|
||||
meme = None
|
||||
form_err = False
|
||||
try:
|
||||
@@ -45,7 +45,7 @@ def new():
|
||||
flash(msg, 'error')
|
||||
if "file" in request.files:
|
||||
return '<script>window.history.back()</script>'
|
||||
return redirect(url_for('meta.index'))
|
||||
return redirect(url_for('meme.index'))
|
||||
if "file" in request.files:
|
||||
if form_err:
|
||||
return '<script>window.history.back()</script>'
|
||||
@@ -65,9 +65,7 @@ def new():
|
||||
print(artwork_hashes)
|
||||
artwork_hash = artwork_hashes[0]['Hash']
|
||||
print(artwork_hash)
|
||||
# client.pin_add(artwork_hash)
|
||||
print(f'[+] Uploaded artwork to IPFS: {artwork_hash}')
|
||||
# Create meta json
|
||||
meta = {
|
||||
'name': title,
|
||||
'description': description,
|
||||
@@ -78,10 +76,9 @@ def new():
|
||||
}
|
||||
}
|
||||
meta_hash = client.add_json(meta)
|
||||
# client.pin_add(meta_hash)
|
||||
print(f'[+] Uploaded metadata to IPFS: {meta_hash}')
|
||||
meme = Meme(
|
||||
upload_path=filename,
|
||||
file_name=filename,
|
||||
meta_ipfs_hash=meta_hash,
|
||||
meme_ipfs_hash=artwork_hash,
|
||||
title=title,
|
||||
@@ -96,16 +93,10 @@ def new():
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return render_template(
|
||||
'new.html',
|
||||
'publish.html',
|
||||
meme=meme
|
||||
)
|
||||
|
||||
@bp.route('/uploads/<path:filename>')
|
||||
def uploaded_file(filename):
|
||||
"""
|
||||
Retrieve an uploaded file from uploads directory.
|
||||
"""
|
||||
return send_from_directory(f'{config.DATA_FOLDER}/uploads', filename)
|
||||
|
||||
@bp.route('/meme/<meme_id>')
|
||||
def meme(meme_id):
|
||||
@@ -113,7 +104,3 @@ def meme(meme_id):
|
||||
if not meme:
|
||||
return redirect('/')
|
||||
return render_template('meme.html', meme=meme)
|
||||
|
||||
@bp.route('/creator/<handle>')
|
||||
def creator(handle):
|
||||
return render_template('includes/creator.html')
|
||||
@@ -0,0 +1,24 @@
|
||||
from flask import Blueprint, render_template, send_from_directory
|
||||
from flask import redirect, url_for
|
||||
from flask_login import logout_user
|
||||
|
||||
from suchwowx import config
|
||||
|
||||
|
||||
bp = Blueprint('meta', 'meta')
|
||||
|
||||
@bp.route('/uploads/<path:filename>')
|
||||
def uploaded_file(filename):
|
||||
"""
|
||||
Retrieve an uploaded file from uploads directory.
|
||||
"""
|
||||
return send_from_directory(f'{config.DATA_FOLDER}/uploads', filename)
|
||||
|
||||
@bp.route('/about')
|
||||
def about():
|
||||
return render_template('about.html')
|
||||
|
||||
@bp.route('/disconnect')
|
||||
def disconnect():
|
||||
logout_user()
|
||||
return redirect(url_for('meme.index'))
|
||||
@@ -7,8 +7,13 @@
|
||||
</p>
|
||||
{% if request.path == '/' %}
|
||||
<a class="button is-secondary" href="{{ url_for('meta.about') }}" up-target=".container">About</a>
|
||||
<a class="button is-primary" href="{{ url_for('meta.new') }}">New Meme</a>
|
||||
{% if current_user.is_authenticated %}
|
||||
<a class="button is-danger" href="{{ url_for('meta.disconnect') }}">Disconnect</a>
|
||||
<a class="button is-primary" href="{{ url_for('meme.publish') }}">New Meme</a>
|
||||
{% else %}
|
||||
<a class="button" href="{{ url_for('meta.index') }}" up-preload up-follow=".container">Go Home</a>
|
||||
<a class="button is-primary" href="#" id="metamaskConnect">Connect</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<a class="button" href="{{ url_for('meme.index') }}" up-preload up-follow=".container">Go Home</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script src="/static/js/vendor/noty-3.2.0.js"></script>
|
||||
<!-- <script src="/static/js/vendor/web3-1.3.6.min.js"></script> -->
|
||||
|
||||
<!-- <script src="/static/js/main.js"></script> -->
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
@@ -18,3 +18,92 @@
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if not current_user.is_authenticated %}
|
||||
<script src="/static/js/vendor/web3-1.3.6.min.js"></script>
|
||||
<script src="/static/js/vendor/metamask-onboarding-1.0.1.bundle.js"></script>
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const onboarding = new MetaMaskOnboarding();
|
||||
const onboardButton = document.getElementById('metamaskConnect');
|
||||
let accounts;
|
||||
let nonce = 0;
|
||||
|
||||
async function getSignedData(publicAddress, jsonData) {
|
||||
const signedData = await window.ethereum.request({
|
||||
method: 'eth_signTypedData_v3',
|
||||
params: [publicAddress, JSON.stringify(jsonData)]
|
||||
});
|
||||
console.log(signedData);
|
||||
return signedData
|
||||
}
|
||||
|
||||
const connectButton = async () => {
|
||||
if (!MetaMaskOnboarding.isMetaMaskInstalled()) {
|
||||
// onboardButton.innerText = 'Click here to install MetaMask!';
|
||||
onboardButton.onclick = () => {
|
||||
document.getElementById('metamaskConnect').classList.add('is-loading');
|
||||
onboardButton.disabled = true;
|
||||
onboarding.startOnboarding();
|
||||
};
|
||||
} else if (accounts && accounts.length > 0) {
|
||||
document.getElementById('metamaskConnect').classList.remove('is-loading');
|
||||
onboardButton.disabled = true;
|
||||
onboarding.stopOnboarding();
|
||||
} else {
|
||||
onboardButton.onclick = async () => {
|
||||
let userExists;
|
||||
const allAccounts = await window.ethereum.request({
|
||||
method: 'eth_requestAccounts',
|
||||
});
|
||||
await fetch('{{ url_for("api.user_exists") }}?public_address=' + allAccounts[0])
|
||||
.then((resp) => resp.json())
|
||||
.then(function(data) {
|
||||
if (!data['success']) {
|
||||
console.log('error checking user_exists!')
|
||||
return
|
||||
}
|
||||
console.log(data);
|
||||
nonce = data['nonce'];
|
||||
})
|
||||
|
||||
const msg = 'Authentication request from SuchWowX app! Verifying message with nonce ' + nonce
|
||||
const signedData = await window.ethereum.request({
|
||||
method: 'personal_sign',
|
||||
params: [msg, allAccounts[0]]
|
||||
});
|
||||
console.log(`Signing data with msg "${msg}", address "${allAccounts[0]}", signed data: ${signedData}`)
|
||||
|
||||
await fetch('{{ url_for("api.authenticate_metamask" ) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json;charset=utf-8'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
'signed_data': signedData,
|
||||
'public_address': allAccounts[0],
|
||||
'nonce': nonce,
|
||||
'message': msg,
|
||||
})
|
||||
})
|
||||
.then((resp) => resp.json())
|
||||
.then(function(data) {
|
||||
console.log(data)
|
||||
if (data['success']) {
|
||||
window.location.href = '/'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
connectButton();
|
||||
if (MetaMaskOnboarding.isMetaMaskInstalled()) {
|
||||
window.ethereum.on('accountsChanged', (newAccounts) => {
|
||||
accounts = newAccounts;
|
||||
connectButton();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
{% include 'includes/navbar.html' %}
|
||||
|
||||
{% if get_flashed_messages(with_categories=true) %}
|
||||
<div class="screen">
|
||||
<p>
|
||||
</br>
|
||||
You can run your own local IPFS instance and maintain metadata and artwork,
|
||||
</br>
|
||||
try installing the software and running the following:
|
||||
</p>
|
||||
<code>$ ipfs daemon</code>
|
||||
</div>
|
||||
</br></br>
|
||||
{% endif %}
|
||||
|
||||
@@ -25,14 +26,14 @@
|
||||
<div class="card">
|
||||
<div class="card-image">
|
||||
<figure class="image">
|
||||
<a href="{{ url_for('meta.meme', meme_id=meme.id) }}" up-preload up-follow=".container">
|
||||
{% if meme.upload_path.endswith('mp4') %}
|
||||
<a href="{{ url_for('meme.meme', meme_id=meme.id) }}" up-preload up-follow=".container">
|
||||
{% if meme.file_name.endswith('mp4') %}
|
||||
<video class="img-fluid" {% if not request.MOBILE %}autoplay{% else %}controls{% endif %} muted loop>
|
||||
<source src="{{ url_for('meta.uploaded_file', filename=meme.upload_path) }}" type="video/mp4">
|
||||
<source src="{{ url_for('meta.uploaded_file', filename=meme.file_name) }}" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
{% else %}
|
||||
<img alt="{{ meme.title }}" src="{{ url_for('meta.uploaded_file', filename=meme.upload_path) }}" width="200px" class="img-fluid" style="" />
|
||||
<img alt="{{ meme.title }}" src="{{ url_for('meta.uploaded_file', filename=meme.file_name) }}" width="200px" class="img-fluid" style="" />
|
||||
{% endif %}
|
||||
</a>
|
||||
</figure>
|
||||
|
||||
@@ -10,18 +10,18 @@
|
||||
{% if meme %}
|
||||
<div id="screen">
|
||||
<div class="screen">
|
||||
{% if meme.upload_path.endswith('mp4') %}
|
||||
{% if meme.file_name.endswith('mp4') %}
|
||||
<video style="max-height: 60vh!important;max-width:100%;" {% if not request.MOBILE %}autoplay{% else %}controls{% endif %} muted loop>
|
||||
<source src="{{ url_for('meta.uploaded_file', filename=meme.upload_path) }}" type="video/mp4">
|
||||
<source src="{{ url_for('meta.uploaded_file', filename=meme.file_name) }}" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
{% else %}
|
||||
<img src="{{ url_for('meta.uploaded_file', filename=meme.upload_path) }}" id="memeImage" />
|
||||
<img src="{{ url_for('meta.uploaded_file', filename=meme.file_name) }}" id="memeImage" />
|
||||
{% endif %}
|
||||
</br>
|
||||
<p>Title: <strong>{{ meme.title }}</strong></p>
|
||||
<p>Description: <strong>{{ meme.description }}</strong></p>
|
||||
<p>Creator handle: <a href="{{ url_for('meta.creator', handle=meme.creator_handle) }}" up-layer="new" up-align="center">{{ meme.creator_handle }}</a></p>
|
||||
<p>Creator handle: <a up-layer="new" up-align="center">{{ meme.creator_handle }}</a></p>
|
||||
<p>Meta IPFS: <a href="{{ config.IPFS_SERVER }}/ipfs/{{ meme.meta_ipfs_hash }}" target=_self up-preload up-follow=".container">{{ meme.meta_ipfs_hash }}</a></p>
|
||||
<p>Meme IPFS: <a href="{{ config.IPFS_SERVER }}/ipfs/{{ meme.meme_ipfs_hash }}" target=_self up-preload up-follow=".container">{{ meme.meme_ipfs_hash }}</a></p>
|
||||
<p>Meme ID: <code>{{ meme }}</code></p>
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<p class="subtitle">
|
||||
Memes. <strong>Interplanetary</strong>!
|
||||
</p>
|
||||
<a class="button" href="{{ url_for('meta.index') }}" up-preload up-follow=".container">Go Back</a>
|
||||
<a class="button" href="{{ url_for('meme.index') }}" up-preload up-follow=".container">Go Back</a>
|
||||
|
||||
<form method="POST" enctype="multipart/form-data" class="site-form" id="memeUpload" action="{{ url_for('meta.new') }}" style="padding-top:1.5em;">
|
||||
<form method="POST" enctype="multipart/form-data" class="site-form" id="memeUpload" action="{{ url_for('meme.publish') }}" style="padding-top:1.5em;">
|
||||
<div class="field">
|
||||
<label class="label">File</label>
|
||||
<div class="control">
|
||||
Reference in New Issue
Block a user