init
commit
5e50fd1640
@ -0,0 +1,4 @@
|
||||
.venv
|
||||
.env
|
||||
data
|
||||
__pycache__
|
@ -0,0 +1,38 @@
|
||||
FROM ubuntu:22.04
|
||||
|
||||
ENV MONERO_HASH 937dfcc48d91748dd2e8f58714dfc45d17a0959dff33fc7385bbe06344ff2c16
|
||||
ENV MONERO_DL_URL https://downloads.getmonero.org/cli/monero-linux-x64-v0.18.1.1.tar.bz2
|
||||
ENV MONERO_DL_FILE monero.tar.bz2
|
||||
ENV MONERO_SUMS_FILE sha256sums
|
||||
|
||||
WORKDIR /opt/monero
|
||||
|
||||
# Update system and install dependencies
|
||||
# Download Monero binaries from Github
|
||||
# Confirm hashes match
|
||||
# Install binaries to system path
|
||||
# Clean up
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get upgrade -y
|
||||
RUN apt-get install -y tar wget bzip2
|
||||
|
||||
RUN wget -qO ${MONERO_DL_FILE} ${MONERO_DL_URL}
|
||||
RUN echo "${MONERO_HASH} ${MONERO_DL_FILE}" > ${MONERO_SUMS_FILE} \
|
||||
&& sha256sum -c ${MONERO_SUMS_FILE}; \
|
||||
if [ "$?" -eq 0 ]; \
|
||||
then \
|
||||
echo -e "[+] Hashes match - proceeding with container build"; \
|
||||
else \
|
||||
echo -e "[!] Hashes do not match - exiting"; \
|
||||
exit 5; \
|
||||
fi \
|
||||
&& mkdir ./tmp \
|
||||
&& tar xvf ${MONERO_DL_FILE} -C ./tmp --strip 1 \
|
||||
&& mv ./tmp/* /usr/local/bin/ \
|
||||
&& rm -rf ./tmp ${MONERO_SUMS_FILE} ${MONERO_DL_FILE}
|
||||
|
||||
WORKDIR /tmp
|
||||
COPY bin/run_wallet.sh /run_wallet.sh
|
||||
|
||||
WORKDIR /data
|
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 @lza_menace (https://lzahq.tech)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
@ -0,0 +1,33 @@
|
||||
.PHONY: format help
|
||||
|
||||
# Help system from https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
help:
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
setup: ## Establish local environment with dependencies installed
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
mkdir -p data/uploads
|
||||
|
||||
build: ## Build containers
|
||||
docker-compose build
|
||||
|
||||
up: ## Start containers
|
||||
docker-compose up -d
|
||||
|
||||
down: ## Stop containers
|
||||
docker-compose down
|
||||
|
||||
dbshell:
|
||||
docker-compose exec db psql -U nerochan
|
||||
|
||||
shell: ## Start Flask CLI shell
|
||||
./manage.sh shell
|
||||
|
||||
init: ## Initialize SQLite DB
|
||||
./manage.sh init
|
||||
|
||||
dev: ## Start Flask development web server
|
||||
./manage.sh run
|
@ -0,0 +1,7 @@
|
||||
# nerochan
|
||||
|
||||
Post your best neroChans. Get tipped.
|
||||
|
||||
No middle-men. p2p tips.
|
||||
|
||||
No passwords. Wallet signing authentication.
|
@ -0,0 +1,38 @@
|
||||
#!/bin/sh
|
||||
|
||||
export RPC_CREDS="${2}"
|
||||
export DAEMON_ADDRESS="${3}"
|
||||
|
||||
# Define the network we plan to operate our wallet in
|
||||
if [[ "${1}" == "stagenet" ]]; then
|
||||
export NETWORK=--stagenet
|
||||
export PORT=38081
|
||||
elif [[ "${1}" == "testnet" ]]; then
|
||||
export NETWORK=--testnet
|
||||
export PORT=28081
|
||||
else
|
||||
export NETWORK=
|
||||
export PORT=18089
|
||||
fi
|
||||
|
||||
# Create new wallet if it doesn't exist
|
||||
if [[ ! -d /data/wallet ]]; then
|
||||
monero-wallet-cli ${NETWORK} \
|
||||
--generate-new-wallet /data/wallet \
|
||||
--daemon-address ${DAEMON_ADDRESS} \
|
||||
--trusted-daemon \
|
||||
--use-english-language-names \
|
||||
--mnemonic-language English
|
||||
fi
|
||||
|
||||
# Run RPC wallet
|
||||
monero-wallet-rpc ${NETWORK} \
|
||||
--daemon-address ${DAEMON_ADDRESS} \
|
||||
--wallet-file /data/wallet \
|
||||
--password "" \
|
||||
--rpc-login ${RPC_CREDS} \
|
||||
--rpc-bind-port 8000 \
|
||||
--rpc-bind-ip 0.0.0.0 \
|
||||
--confirm-external-bind \
|
||||
--log-file /data/wallet-rpc.log \
|
||||
--trusted-daemon
|
@ -0,0 +1,16 @@
|
||||
version: '3'
|
||||
services:
|
||||
cache:
|
||||
image: redis:latest
|
||||
ports:
|
||||
- 127.0.0.1:6379:6379
|
||||
wallet:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile-monero
|
||||
ports:
|
||||
- 127.0.0.1:${XMR_WALLET_RPC_PORT:-8000}:${XMR_WALLET_RPC_PORT:-8000}
|
||||
volumes:
|
||||
- ${DATA_DIR:-./data/wallet}:/data
|
||||
command:
|
||||
bash /run_wallet.sh "${XMR_WALLET_NETWORK}" "${XMR_WALLET_RPC_USER}:${XMR_WALLET_RPC_PASS}" "${XMR_DAEMON_URI}"
|
@ -0,0 +1,12 @@
|
||||
DB_PATH=./data/sqlite.db
|
||||
|
||||
XMR_WALLET_RPC_USER=xxxxxxxxxx
|
||||
XMR_WALLET_RPC_PASS=xxxxxxxxxxxxxxxxxxx
|
||||
XMR_WALLET_RPC_PORT=8000
|
||||
XMR_DAEMON_URI=http://super.fast.node.xmr.pm:38089
|
||||
XMR_WALLET_NETWORK=stagenet
|
||||
|
||||
SITE_NAME=nerochan
|
||||
SECRET_KEY=xxxxxxxxxxxxxxxxxxx
|
||||
STATS_TOKEN=xxxxxxxxxxxxxxxxxxxx
|
||||
SERVER_NAME=127.0.0.1:5000
|
@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
source .venv/bin/activate
|
||||
export FLASK_APP=nerochan/app.py
|
||||
export FLASK_SECRETS=config.py
|
||||
export FLASK_DEBUG=1
|
||||
export FLASK_ENV=development
|
||||
|
||||
# override
|
||||
source .env
|
||||
|
||||
if [[ ${1} == "prod" ]];
|
||||
then
|
||||
export FLASK_DEBUG=0
|
||||
export FLASK_ENV=production
|
||||
export BASE=./data/gunicorn
|
||||
mkdir -p $BASE
|
||||
pgrep -F $BASE/gunicorn.pid
|
||||
if [[ $? != 0 ]]; then
|
||||
gunicorn \
|
||||
--bind 127.0.0.1:4000 "nerochan.app:app" \
|
||||
--daemon \
|
||||
--log-file $BASE/gunicorn.log \
|
||||
--pid $BASE/gunicorn.pid \
|
||||
--reload
|
||||
sleep 2
|
||||
echo "Started gunicorn on 127.0.0.1:4000 with pid $(cat $BASE/gunicorn.pid)"
|
||||
fi
|
||||
else
|
||||
flask $@
|
||||
fi
|
@ -0,0 +1 @@
|
||||
from nerochan.factory import create_app
|
@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from nerochan import create_app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, use_reloader=True)
|
@ -0,0 +1,12 @@
|
||||
import click
|
||||
|
||||
|
||||
def cli(app):
|
||||
@app.cli.command('init')
|
||||
def init():
|
||||
import peewee
|
||||
from nerochan.models import db
|
||||
model = peewee.Model.__subclasses__()
|
||||
db.create_tables(model)
|
||||
|
||||
return app
|
@ -0,0 +1,35 @@
|
||||
from secrets import token_urlsafe
|
||||
from datetime import timedelta
|
||||
from os import getenv
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Site meta
|
||||
SITE_NAME = getenv('SITE_NAME', 'nerochan')
|
||||
SECRET_KEY = getenv('SECRET_KEY', token_urlsafe(12))
|
||||
SERVER_NAME = getenv('SERVER_NAME', '127.0.0.1:5000')
|
||||
|
||||
# Crypto RPC
|
||||
XMR_WALLET_PASS = getenv('XMR_WALLET_PASS')
|
||||
XMR_WALLET_RPC_USER = getenv('XMR_WALLET_RPC_USER')
|
||||
XMR_WALLET_RPC_PASS = getenv('XMR_WALLET_RPC_PASS')
|
||||
XMR_WALLET_RPC_PORT = getenv('XMR_WALLET_RPC_PORT', 8000)
|
||||
XMR_WALLET_NETWORK = getenv('XMR_WALLET_NETWORK')
|
||||
|
||||
# Database
|
||||
DB_PATH = getenv('DB_PATH', './data/sqlite.db')
|
||||
|
||||
# Redis
|
||||
REDIS_HOST = getenv('REDIS_HOST', 'localhost')
|
||||
REDIS_PORT = getenv('REDIS_PORT', 6379)
|
||||
|
||||
# Sessions
|
||||
SESSION_LENGTH = int(getenv('SESSION_LENGTH', 300))
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(minutes=SESSION_LENGTH)
|
||||
MAX_CONTENT_LENGTH = 50 * 1024 * 1024
|
||||
|
||||
# Development
|
||||
TEMPLATES_AUTO_RELOAD = True
|
@ -0,0 +1,25 @@
|
||||
from flask import session, redirect, url_for, flash
|
||||
from flask_login import current_user
|
||||
from functools import wraps
|
||||
from nerochan.models import User, CreatorProfile, BackerProfile, Subscription
|
||||
|
||||
|
||||
# def login_required(f):
|
||||
# @wraps(f)
|
||||
# def decorated_function(*args, **kwargs):
|
||||
# if "auth" not in session or not session["auth"]:
|
||||
# return redirect(url_for("auth.login"))
|
||||
# return f(*args, **kwargs)
|
||||
# return decorated_function
|
||||
|
||||
def subscription_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
print(current_user)
|
||||
# m = Moderator.filter(username=session["auth"]["preferred_username"])
|
||||
# if m:
|
||||
# return f(*args, **kwargs)
|
||||
# else:
|
||||
# flash("You are not a moderator")
|
||||
# return redirect(url_for("index"))
|
||||
return decorated_function
|
@ -0,0 +1,38 @@
|
||||
from flask import Flask
|
||||
from flask_login import LoginManager
|
||||
|
||||
from nerochan.cli import cli
|
||||
|
||||
|
||||
def setup_db(app: Flask):
|
||||
import peewee
|
||||
models = peewee.Model.__subclasses__()
|
||||
for m in models:
|
||||
m.create_table()
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app = cli(app)
|
||||
app.config.from_envvar('FLASK_SECRETS')
|
||||
|
||||
# Login manager
|
||||
login_manager = LoginManager(app)
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.logout_view = 'auth.logout'
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
from nerochan.models import User
|
||||
return User.get_or_none(user_id)
|
||||
|
||||
with app.app_context():
|
||||
from nerochan.routes import api, auth, post, main
|
||||
from nerochan import filters
|
||||
app.register_blueprint(main.bp)
|
||||
app.register_blueprint(api.bp)
|
||||
app.register_blueprint(auth.bp)
|
||||
app.register_blueprint(post.bp)
|
||||
app.register_blueprint(filters.bp)
|
||||
|
||||
return app
|
@ -0,0 +1,25 @@
|
||||
from datetime import datetime
|
||||
|
||||
import arrow
|
||||
from monero import numbers
|
||||
from flask import Blueprint, current_app
|
||||
|
||||
|
||||
bp = Blueprint('filters', 'filters')
|
||||
|
||||
|
||||
@bp.app_template_filter('humanize')
|
||||
def humanize(d):
|
||||
return arrow.get(d).humanize()
|
||||
|
||||
@bp.app_template_filter('ts')
|
||||
def from_ts(v):
|
||||
return datetime.fromtimestamp(v)
|
||||
|
||||
@bp.app_template_filter('xmr_block_explorer')
|
||||
def xmr_block_explorer(v):
|
||||
return f'https://www.exploremonero.com/transaction/{v}'
|
||||
|
||||
@bp.app_template_filter('from_atomic')
|
||||
def from_atomic(amt):
|
||||
return numbers.from_atomic(amt)
|
@ -0,0 +1,47 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, FloatField
|
||||
from wtforms.validators import DataRequired, ValidationError
|
||||
from monero.address import address
|
||||
|
||||
from nerochan import config
|
||||
|
||||
|
||||
def is_valid_xmr_address(form, field):
|
||||
try:
|
||||
# Ensure the provided address is valid address/subaddress/integrated address
|
||||
a = address(field.data)
|
||||
print(config.XMR_WALLET_NETWORK)
|
||||
# Ensure the provided address matches the network that the application's wallet is using
|
||||
if not config.XMR_WALLET_NETWORK.startswith(a.net):
|
||||
raise ValidationError('Provided Monero address does not match the configured network. Application: {}. Provided: {}'.format(
|
||||
config.XMR_WALLET_NETWORK.replace('net', ''), a.net
|
||||
))
|
||||
except ValueError:
|
||||
raise ValidationError('Invalid Monero address provided')
|
||||
|
||||
|
||||
class UserRegistration(FlaskForm):
|
||||
handle = StringField('Handle:', validators=[DataRequired()], render_kw={'placeholder': 'online handle', 'class': 'form-control', 'type': 'text'})
|
||||
wallet_address = StringField('Wallet Address:', validators=[DataRequired(), is_valid_xmr_address], render_kw={'placeholder': 'monero wallet address', 'class': 'form-control', 'type': 'text'})
|
||||
|
||||
|
||||
class UserLogin(FlaskForm):
|
||||
handle = StringField('Handle:', validators=[DataRequired()], render_kw={'placeholder': 'online handle', 'class': 'form-control', 'type': 'text'})
|
||||
|
||||
|
||||
class UserChallenge(FlaskForm):
|
||||
signature = StringField('Signature:', validators=[DataRequired()], render_kw={'placeholder': 'signed data', 'class': 'form-control', 'type': 'text'})
|
||||
|
||||
|
||||
class ConfirmPlatformSubscription(FlaskForm):
|
||||
tx_id = StringField('TX ID:', validators=[DataRequired()], render_kw={'placeholder': 'TX ID', 'class': 'form-control', 'type': 'text'})
|
||||
tx_key = StringField('TX Key:', validators=[DataRequired()], render_kw={'placeholder': 'TX Key', 'class': 'form-control', 'type': 'text'})
|
||||
|
||||
|
||||
class ConfirmCreatorSubscription(ConfirmPlatformSubscription):
|
||||
wallet_address = StringField('Wallet Address:', validators=[DataRequired(), is_valid_xmr_address], render_kw={'placeholder': 'monero wallet address', 'class': 'form-control', 'type': 'text'})
|
||||
|
||||
|
||||
class CreateSubscription(FlaskForm):
|
||||
price_xmr = FloatField('Price (XMR):', validators=[DataRequired()], render_kw={'placeholder': '.5', 'class': 'form-control', 'type': 'text'})
|
||||
number_days = FloatField('Length (Days)', validators=[DataRequired()], render_kw={'placeholder': '30', 'class': 'form-control', 'type': 'text'})
|
@ -0,0 +1,19 @@
|
||||
import peewee as pw
|
||||
import playhouse.postgres_ext as pwpg
|
||||
from monero.wallet import Wallet
|
||||
|
||||
from nerochan import config
|
||||
|
||||
|
||||
def make_wallet_rpc(method, data={}):
|
||||
try:
|
||||
w = Wallet(
|
||||
port=config.XMR_WALLET_RPC_PORT,
|
||||
user=config.XMR_WALLET_RPC_USER,
|
||||
password=config.XMR_WALLET_RPC_PASS,
|
||||
timeout=3
|
||||
)
|
||||
res = w._backend.raw_request(method, data)
|
||||
return res
|
||||
except Exception as e:
|
||||
raise e
|
@ -0,0 +1,35 @@
|
||||
from json import loads as json_loads
|
||||
from json import dumps as json_dumps
|
||||
from datetime import timedelta
|
||||
|
||||
from redis import Redis
|
||||
|
||||
from nerochan.library.market import get_market_data
|
||||
from nerochan import config
|
||||
|
||||
|
||||
class Cache(object):
|
||||
def __init__(self):
|
||||
self.redis = Redis(host=config.REDIS_HOST, port=config.REDIS_PORT)
|
||||
|
||||
def store_data(self, item_name, expiration_minutes, data):
|
||||
self.redis.setex(
|
||||
item_name,
|
||||
timedelta(minutes=expiration_minutes),
|
||||
value=data
|
||||
)
|
||||
|
||||
def get_coin_price(self, coin_name):
|
||||
key_name = f'{coin_name}_price'
|
||||
data = self.redis.get(key_name)
|
||||
if data:
|
||||
return json_loads(data)
|
||||
else:
|
||||
d = get_market_data(coin_name)
|
||||
data = {
|
||||
key_name: d['market_data']['current_price'],
|
||||
}
|
||||
self.store_data(key_name, 4, json_dumps(data))
|
||||
return data
|
||||
|
||||
cache = Cache()
|
@ -0,0 +1,16 @@
|
||||
from requests import get as r_get
|
||||
|
||||
|
||||
def get_market_data(coin_name="monero"):
|
||||
data = {
|
||||
'localization': False,
|
||||
'tickers': False,
|
||||
'market_data': True,
|
||||
'community_data': False,
|
||||
'developer_data': False,
|
||||
'sparkline': False
|
||||
}
|
||||
headers = {'accept': 'application/json'}
|
||||
url = f'https://api.coingecko.com/api/v3/coins/{coin_name}'
|
||||
r = r_get(url, headers=headers, data=data)
|
||||
return r.json()
|
@ -0,0 +1,103 @@
|
||||
from datetime import datetime
|
||||
from secrets import token_urlsafe
|
||||
|
||||
import peewee as pw
|
||||
|
||||
from nerochan import config
|
||||
|
||||
|
||||
db = pw.SqliteDatabase(
|
||||
config.DB_PATH
|
||||
)
|
||||
|
||||
def gen_challenge():
|
||||
return token_urlsafe().replace('-', '').replace('_', '')
|
||||
|
||||
|
||||
class User(pw.Model):
|
||||
"""
|
||||
User model is for base user management and reporting.
|
||||
"""
|
||||
id = pw.AutoField()
|
||||
register_date = pw.DateTimeField(default=datetime.utcnow)
|
||||
last_login_date = pw.DateTimeField(default=datetime.utcnow)
|
||||
handle = pw.CharField(unique=True)
|
||||
wallet_address = pw.CharField(unique=True)
|
||||
challenge = pw.CharField(default=gen_challenge)
|
||||
is_admin = pw.BooleanField(default=False)
|
||||
is_mod = pw.BooleanField(default=False)
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_anonymous(self):
|
||||
return False
|
||||
|
||||
def get_id(self):
|
||||
return self.id
|
||||
|
||||
def regenerate_challenge(self):
|
||||
self.challenge = gen_challenge()
|
||||
self.save()
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
|
||||
|
||||
class Profile(pw.Model):
|
||||
"""
|
||||
Profile model is for users to provide metadata about
|
||||
themselves; Creators for their fans or even just the general public.
|
||||
Links to social media, contact info, portfolio sites, etc
|
||||
should go in here.
|
||||
"""
|
||||
id = pw.AutoField()
|
||||
user = pw.ForeignKeyField(User)
|
||||
create_date = pw.DateTimeField(default=datetime.utcnow)
|
||||
website = pw.CharField(unique=True, null=True)
|
||||
twitter_handle = pw.CharField(unique=True, null=True)
|
||||
bio = pw.CharField(null=True)
|
||||
email = pw.CharField(unique=True, null=True)
|
||||
verified = pw.CharField(default=False)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
|
||||
|
||||
class Content(pw.Model):
|
||||
"""
|
||||
Content model is any uploaded content from a creator.
|
||||
"""
|
||||
id = pw.AutoField()
|
||||
creator = pw.ForeignKeyField(User)
|
||||
path = pw.CharField()
|
||||
upload_date = pw.DateTimeField(default=datetime.utcnow)
|
||||
last_edit_date = pw.DateTimeField(default=datetime.utcnow)
|
||||
approved = pw.BooleanField(default=False)
|
||||
hidden = pw.BooleanField(default=False)
|
||||
title = pw.CharField()
|
||||
description = pw.TextField()
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
|
||||
|
||||
class Transaction(pw.Model):
|
||||
"""
|
||||
Transaction model is a simple reference to a Monero transaction so that we can track
|
||||
which transactions have occurred on-chain and what content they correspond to.
|
||||
"""
|
||||
id = pw.AutoField()
|
||||
tx_id = pw.CharField(unique=True)
|
||||
atomic_xmr = pw.BigIntegerField()
|
||||
to_address = pw.CharField()
|
||||
content = pw.ForeignKeyField(Content)
|
||||
|
||||
class Meta:
|
||||
database = db
|
@ -0,0 +1,11 @@
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
|
||||
bp = Blueprint('api', 'api')
|
||||
|
||||
@bp.route('/api/test')
|
||||
def get_prices():
|
||||
return jsonify({
|
||||
'test': True,
|
||||
'message': 'This is only a test.'
|
||||
})
|
@ -0,0 +1,102 @@
|
||||
from flask import Blueprint, render_template
|
||||
from flask import flash, redirect, url_for
|
||||
from flask_login import login_user, logout_user, current_user
|
||||
|
||||
from nerochan.forms import UserLogin, UserRegistration, UserChallenge
|
||||
from nerochan.helpers import make_wallet_rpc
|
||||
from nerochan.models import User
|
||||
|
||||
|
||||
bp = Blueprint('auth', 'auth')
|
||||
|
||||
@bp.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
form = UserRegistration()
|
||||
if current_user.is_authenticated:
|
||||
flash('Already registered and authenticated.')
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Check if handle already exists
|
||||
user = User.select().where(
|
||||
User.handle == form.handle.data
|
||||
).first()
|
||||
if user:
|
||||
flash('This handle is already registered.')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# Save new user
|
||||
user = User(
|
||||
handle=form.handle.data,
|
||||
wallet_address=form.wallet_address.data,
|
||||
)
|
||||
user.save()
|
||||
login_user(user)
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
return render_template("auth/register.html", form=form)
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
form = UserLogin()
|
||||
if current_user.is_authenticated:
|
||||
flash('Already logged in.')
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Check if user doesn't exist
|
||||
user = User.select().where(
|
||||
User.handle == form.handle.data
|
||||
).first()
|
||||
if not user:
|
||||
flash('That handle does not exist.')
|
||||
return redirect(url_for('auth.login'))
|
||||
return redirect(url_for('auth.challenge', handle=user.handle))
|
||||
|
||||
return render_template("auth/login.html", form=form)
|
||||
|
||||
@bp.route("/login/challenge/<handle>", methods=["GET", "POST"])
|
||||
def challenge(handle):
|
||||
form = UserChallenge()
|
||||
user = User.select().where(User.handle == handle).first()
|
||||
if not user:
|
||||
flash('User does not exist.')
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
if current_user.is_authenticated:
|
||||
flash('Already logged in.')
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
if form.validate_on_submit():
|
||||
data = {
|
||||
'data': user.challenge,
|
||||
'address': user.wallet_address,
|
||||
'signature': form.signature.data
|
||||
}
|
||||
try:
|
||||
res = make_wallet_rpc('verify', data)
|
||||
if res['good']:
|
||||
user.regenerate_challenge()
|
||||
login_user(user)
|
||||
flash('Successful login!')
|
||||
return redirect(url_for('main.index'))
|
||||
else:
|
||||
flash('Invalid signature. Try again.')
|
||||
return redirect(url_for('auth.challenge', handle=handle))
|
||||
except Exception as e:
|
||||
flash(f'Issue with checking the signature provided: {e}')
|
||||
return redirect(url_for('auth.challenge', handle=handle))
|
||||
|
||||
return render_template(
|
||||
'auth/challenge.html',
|
||||
user=user,
|
||||
form=form
|
||||
)
|
||||
|
||||
@bp.route("/logout")
|
||||
def logout():
|
||||
if current_user.is_authenticated:
|
||||
logout_user()
|
||||
else:
|
||||
flash('Not authenticated!')
|
||||
return redirect(url_for('main.index'))
|
@ -0,0 +1,30 @@
|
||||
from flask import Blueprint, render_template
|
||||
from flask_login import current_user
|
||||
|
||||
from nerochan.models import *
|
||||
|
||||
|
||||
bp = Blueprint('main', 'main')
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
feed = dict()
|
||||
# new_creators = User.select().where(
|
||||
# User.roles.contains_any(UserRole.creator)
|
||||
# ).order_by(User.register_date.desc()).execute()
|
||||
# feed['new_creators'] = new_creators
|
||||
# if current_user.is_authenticated:
|
||||
# active_subscriptions = Subscription.select().where(
|
||||
# Subscription.is_active == True,
|
||||
# Subscription.backer == current_user
|
||||
# ).order_by(Subscription.subscribe_date.desc()).execute()
|
||||
# feed['active_subscriptions'] = active_subscriptions
|
||||
# new_posts = Post.select().where(
|
||||
# Post.hidden == False,
|
||||
# Post.creator in [c.creator for c in active_subscriptions]
|
||||
# ).order_by(Post.post_date.desc()).execute()
|
||||
# feed['new_posts'] = new_posts
|
||||
return render_template(
|
||||
'index.html',
|
||||
feed=feed
|
||||
)
|
@ -0,0 +1,27 @@
|
||||
from flask import Blueprint, render_template, flash, redirect, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
# from nerochan.models import Content, User
|
||||
|
||||
|
||||
bp = Blueprint('post', 'post')
|
||||
|
||||
# @bp.route('/post/<int:post_id>')
|
||||
# def show(post_id):
|
||||
# post = TextPost.get_or_none(post_id)
|
||||
# if post:
|
||||
# if current_user.is_anonymous:
|
||||
# flash('You must login to view this post.')
|
||||
# return redirect(url_for('creator.show', username=post.creator.user.username))
|
||||
# user_subscriptions = Subscription.select().where(
|
||||
# Subscription.active == True,
|
||||
# Subscription.backer == current_user.backer_profile.first()
|
||||
# )
|
||||
# if user_subscriptions:
|
||||
# return render_template('post/show.html', post=post)
|
||||
# else:
|
||||
# flash('Viewing posts requires a subscription.')
|
||||
# return redirect(url_for('creator.subscription', username=post.creator.user.username))
|
||||
# else:
|
||||
# flash('That post does not exist.')
|
||||
# return redirect(url_for('meta.index'))
|
@ -0,0 +1,46 @@
|
||||
.noty_theme__relax.noty_bar {
|
||||
margin: 4px 0;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
position: relative; }
|
||||
.noty_theme__relax.noty_bar .noty_body {
|
||||
padding: 10px; }
|
||||
.noty_theme__relax.noty_bar .noty_buttons {
|
||||
border-top: 1px solid #e7e7e7;
|
||||
padding: 5px 10px; }
|
||||
|
||||
.noty_theme__relax.noty_type__alert,
|
||||
.noty_theme__relax.noty_type__notification {
|
||||
background-color: #fff;
|
||||
border: 1px solid #dedede;
|
||||
color: #444; }
|
||||
|
||||
.noty_theme__relax.noty_type__warning {
|
||||
background-color: #FFEAA8;
|
||||
border: 1px solid #FFC237;
|
||||
color: #826200; }
|
||||
.noty_theme__relax.noty_type__warning .noty_buttons {
|
||||
border-color: #dfaa30; }
|
||||
|
||||
.noty_theme__relax.noty_type__error {
|
||||
background-color: #FF8181;
|
||||
border: 1px solid #e25353;
|
||||
color: #FFF; }
|
||||
.noty_theme__relax.noty_type__error .noty_buttons {
|
||||
border-color: darkred; }
|
||||
|
||||
.noty_theme__relax.noty_type__info,
|
||||
.noty_theme__relax.noty_type__information {
|
||||
background-color: #78C5E7;
|
||||
border: 1px solid #3badd6;
|
||||
color: #FFF; }
|
||||
.noty_theme__relax.noty_type__info .noty_buttons,
|
||||
.noty_theme__relax.noty_type__information .noty_buttons {
|
||||
border-color: #0B90C4; }
|
||||
|
||||
.noty_theme__relax.noty_type__success {
|
||||
background-color: #BCF5BC;
|
||||
border: 1px solid #7cdd77;
|
||||
color: darkgreen; }
|
||||
.noty_theme__relax.noty_type__success .noty_buttons {
|
||||
border-color: #50C24E; }
|
@ -0,0 +1,222 @@
|
||||
.noty_layout_mixin, #noty_layout__top, #noty_layout__topLeft, #noty_layout__topCenter, #noty_layout__topRight, #noty_layout__bottom, #noty_layout__bottomLeft, #noty_layout__bottomCenter, #noty_layout__bottomRight, #noty_layout__center, #noty_layout__centerLeft, #noty_layout__centerRight {
|
||||
position: fixed;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
z-index: 9999999;
|
||||
-webkit-transform: translateZ(0) scale(1, 1);
|
||||
transform: translateZ(0) scale(1, 1);
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
filter: blur(0);
|
||||
-webkit-filter: blur(0);
|
||||
max-width: 90%; }
|
||||
|
||||
#noty_layout__top {
|
||||
top: 0;
|
||||
left: 5%;
|
||||
width: 90%; }
|
||||
|
||||
#noty_layout__topLeft {
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
width: 325px; }
|
||||
|
||||
#noty_layout__topCenter {
|
||||
top: 5%;
|
||||
left: 50%;
|
||||
width: 325px;
|
||||
-webkit-transform: translate(-webkit-calc(-50% - .5px)) translateZ(0) scale(1, 1);
|
||||
transform: translate(calc(-50% - .5px)) translateZ(0) scale(1, 1); }
|
||||
|
||||
#noty_layout__topRight {
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 325px; }
|
||||
|
||||
#noty_layout__bottom {
|
||||
bottom: 0;
|
||||
left: 5%;
|
||||
width: 90%; }
|
||||
|
||||
#noty_layout__bottomLeft {
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
width: 325px; }
|
||||
|
||||
#noty_layout__bottomCenter {
|
||||
bottom: 5%;
|
||||
left: 50%;
|
||||
width: 325px;
|
||||
-webkit-transform: translate(-webkit-calc(-50% - .5px)) translateZ(0) scale(1, 1);
|
||||
transform: translate(calc(-50% - .5px)) translateZ(0) scale(1, 1); }
|
||||
|
||||
#noty_layout__bottomRight {
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 325px; }
|
||||
|
||||
#noty_layout__center {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 325px;
|
||||
-webkit-transform: translate(-webkit-calc(-50% - .5px), -webkit-calc(-50% - .5px)) translateZ(0) scale(1, 1);
|
||||
transform: translate(calc(-50% - .5px), calc(-50% - .5px)) translateZ(0) scale(1, 1); }
|
||||
|
||||
#noty_layout__centerLeft {
|
||||
top: 50%;
|
||||
left: 20px;
|
||||
width: 325px;
|
||||
-webkit-transform: translate(0, -webkit-calc(-50% - .5px)) translateZ(0) scale(1, 1);
|
||||
transform: translate(0, calc(-50% - .5px)) translateZ(0) scale(1, 1); }
|
||||
|
||||
#noty_layout__centerRight {
|
||||
top: 50%;
|
||||
right: 20px;
|
||||
width: 325px;
|
||||
-webkit-transform: translate(0, -webkit-calc(-50% - .5px)) translateZ(0) scale(1, 1);
|
||||
transform: translate(0, calc(-50% - .5px)) translateZ(0) scale(1, 1); }
|
||||
|
||||
.noty_progressbar {
|
||||
display: none; }
|
||||
|
||||
.noty_has_timeout.noty_has_progressbar .noty_progressbar {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
background-color: #646464;
|
||||
opacity: 0.2;
|
||||
filter: alpha(opacity=10); }
|
||||
|
||||
.noty_bar {
|
||||
-webkit-backface-visibility: hidden;
|
||||
-webkit-transform: translate(0, 0) translateZ(0) scale(1, 1);
|
||||
-ms-transform: translate(0, 0) scale(1, 1);
|
||||
transform: translate(0, 0) scale(1, 1);
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
overflow: hidden; }
|
||||
|
||||
.noty_effects_open {
|
||||
opacity: 0;
|
||||
-webkit-transform: translate(50%);
|
||||
-ms-transform: translate(50%);
|
||||
transform: translate(50%);
|
||||
-webkit-animation: noty_anim_in 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
animation: noty_anim_in 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
-webkit-animation-fill-mode: forwards;
|
||||
animation-fill-mode: forwards; }
|
||||
|
||||
.noty_effects_close {
|
||||
-webkit-animation: noty_anim_out 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
animation: noty_anim_out 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
-webkit-animation-fill-mode: forwards;
|
||||
animation-fill-mode: forwards; }
|
||||
|
||||
.noty_fix_effects_height {
|
||||
-webkit-animation: noty_anim_height 75ms ease-out;
|
||||
animation: noty_anim_height 75ms ease-out; }
|
||||
|
||||
.noty_close_with_click {
|
||||
cursor: pointer; }
|
||||
|
||||
.noty_close_button {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
font-weight: bold;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
line-height: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
-webkit-transition: all .2s ease-out;
|
||||
transition: all .2s ease-out; }
|
||||
|
||||
.noty_close_button:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1); }
|
||||
|
||||
.noty_modal {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000;
|
||||
z-index: 10000;
|
||||
opacity: .3;
|
||||
left: 0;
|
||||
top: 0; }
|
||||
|
||||
.noty_modal.noty_modal_open {
|
||||
opacity: 0;
|
||||
-webkit-animation: noty_modal_in .3s ease-out;
|
||||
animation: noty_modal_in .3s ease-out; }
|
||||
|
||||
.noty_modal.noty_modal_close {
|
||||
-webkit-animation: noty_modal_out .3s ease-out;
|
||||
animation: noty_modal_out .3s ease-out;
|
||||
-webkit-animation-fill-mode: forwards;
|
||||
animation-fill-mode: forwards; }
|
||||
|
||||
@-webkit-keyframes noty_modal_in {
|
||||
100% {
|
||||
opacity: .3; } }
|
||||
|
||||
@keyframes noty_modal_in {
|
||||
100% {
|
||||
opacity: .3; } }
|
||||
|
||||
@-webkit-keyframes noty_modal_out {
|
||||
100% {
|
||||
opacity: 0; } }
|
||||
|
||||
@keyframes noty_modal_out {
|
||||
100% {
|
||||
opacity: 0; } }
|
||||
|
||||
@keyframes noty_modal_out {
|
||||
100% {
|
||||
opacity: 0; } }
|
||||
|
||||
@-webkit-keyframes noty_anim_in {
|
||||
100% {
|
||||
-webkit-transform: translate(0);
|
||||
transform: translate(0);
|
||||
opacity: 1; } }
|
||||
|
||||
@keyframes noty_anim_in {
|
||||
100% {
|
||||
-webkit-transform: translate(0);
|
||||
transform: translate(0);
|
||||
opacity: 1; } }
|
||||
|
||||
@-webkit-keyframes noty_anim_out {
|
||||
100% {
|
||||
-webkit-transform: translate(50%);
|
||||
transform: translate(50%);
|
||||
opacity: 0; } }
|
||||
|
||||
@keyframes noty_anim_out {
|
||||
100% {
|
||||
-webkit-transform: translate(50%);
|
||||
transform: translate(50%);
|
||||
opacity: 0; } }
|
||||
|
||||
@-webkit-keyframes noty_anim_height {
|
||||
100% {
|
||||
height: 0; } }
|
||||
|
||||
@keyframes noty_anim_height {
|
||||
100% {
|
||||
height: 0; } }
|
||||
|
||||
/*# sourceMappingURL=noty.css.map*/
|
||||
|
||||
|
||||
/* Custom */
|
||||
.noty_body {
|
||||
text-align: center;
|
||||
}
|
@ -0,0 +1 @@
|
||||
{"version":3,"sources":[],"names":[],"mappings":"","file":"noty.css","sourceRoot":""}
|
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
@ -0,0 +1,11 @@
|
||||
{% extends 'includes/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2>Challenge</h2>
|
||||
<p>Handle: {{ user.handle }}</p>
|
||||
<p>Challenge: {{ user.challenge }}</p>
|
||||
<p>Wallet Address: {{ user.wallet_address }}</p>
|
||||
{% include 'includes/form.html' %}
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,8 @@
|
||||
{% extends 'includes/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2>Login</h2>
|
||||
{% include 'includes/form.html' %}
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,8 @@
|
||||
{% extends 'includes/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2>Register</h2>
|
||||
{% include 'includes/form.html' %}
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,18 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
{% include 'includes/head.html' %}
|
||||
<body style="background-color: black; color: white;">
|
||||
{% include 'includes/header.html' %}
|
||||
{% block content %}{% endblock %}
|
||||
{% include 'includes/debug.html' %}
|
||||
<style>
|
||||
body {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
a, a:visited {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,9 @@
|
||||
{% if config.DEBUG and current_user.is_authenticated %}
|
||||
<hr>
|
||||
<h2>Debug</h2>
|
||||
<p>
|
||||
Username: {{ current_user.handle }} <br>
|
||||
{% if current_user.email %}Email: {{ current_user.email }} <br>{% endif %}
|
||||
Wallet Address: {{ current_user.wallet_address }} <br>
|
||||
</p>
|
||||
{% endif %}
|
@ -0,0 +1,18 @@
|
||||
<hr>
|
||||
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
<header>
|
||||
<p>This is a simple prototype and is under heavy development.</p>
|
||||
</header>
|
||||
<span class="image"><img src="/static/images/monero-logo.png" width=150px></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer id="footer">
|
||||
<ul class="icons">
|
||||
</ul>
|
||||
<ul class="copyright">
|
||||
<li>© {{ config.SITE_NAME }}. All rights reserved.</li>
|
||||
</ul>
|
||||
</footer>
|
@ -0,0 +1,18 @@
|
||||
<form method="POST" action="">
|
||||
{% for f in form %}
|
||||
{% if f.name == 'csrf_token' %}
|
||||
{{ f }}
|
||||
{% else %}
|
||||
<div class="form-group">
|
||||
{{ f.label }}
|
||||
{{ f }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<ul>
|
||||
{% for field, errors in form.errors.items() %}
|
||||
<li>{{ form[field].label }}: {{ ', '.join(errors) }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<input type="submit" value="Confirm" class="btn btn-link btn-outline btn-xl">
|
||||
</form>
|
@ -0,0 +1,22 @@
|
||||
<head>
|
||||
<title>{{ config.SITE_NAME }}</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<link rel="shortcut icon" href="/static/images/favicon.ico" type="image/png">
|
||||
<meta itemprop="name" content="MyThing app">
|
||||
<meta itemprop="description" content="This is a sample application making using of Flask, a Flask replacement for Python">
|
||||
<meta itemprop="image" content="">
|
||||
<meta property="og:url" content="">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="">
|
||||
<meta property="og:description" content="This is a sample application making using of Flask, a Flask replacement for Python">
|
||||
<meta property="og:image" content="">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="">
|
||||
<meta name="twitter:description" content="This is a sample application making using of Flask, a Flask replacement for Python">
|
||||
<meta name="twitter:image" content="">
|
||||
<meta name="keywords" content="Wownero, Monero, crypto, swap">
|
||||
<link rel="stylesheet" href="/static/css/main.css" />
|
||||
<link rel="stylesheet" href="/static/css/noty.css">
|
||||
<link rel="stylesheet" href="/static/css/noty-relax.css">
|
||||
</head>
|
@ -0,0 +1,20 @@
|
||||
<header id="header">
|
||||
<h1 id="logo"><a href="/">{{ config.SITE_NAME }}</a></h1>
|
||||
<nav id="nav">
|
||||
<ul>
|
||||
<li><a href="{{ url_for('auth.login') }}">Login</a></li>
|
||||
<li><a href="{{ url_for('auth.register') }}">Register</a></li>
|
||||
<li><a href="{{ url_for('auth.logout') }}">Logout</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<p>
|
||||
{% for category, message in messages %}
|
||||
<p>{{ message }} - {{ category }}</p>
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
@ -0,0 +1,9 @@
|
||||
{% extends 'includes/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<p>logged in: {{ current_user.handle }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,25 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
|
||||
{% include 'includes/head.html' %}
|
||||
|
||||
<body class="is-preload landing">
|
||||
<div id="page-wrapper">
|
||||
|
||||
{% include 'includes/header.html' %}
|
||||
|
||||
{% if post %}
|
||||
<h1>{{ post.title }}</h1>
|
||||
<p>Posted: {{ post.post_date }}</p>
|
||||
<p>Edited: {{ post.last_edit_date }}</p>
|
||||
<p>{{ post.content }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% include 'includes/footer.html' %}
|
||||
|
||||
</div>
|
||||
|
||||
{% include 'includes/scripts.html' %}
|
||||
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,17 @@
|
||||
Flask-WTF
|
||||
flask-login
|
||||
flask-bcrypt
|
||||
hypercorn
|
||||
Pillow
|
||||
psycopg2-binary
|
||||
python-dotenv
|
||||
qrcode
|
||||
redis
|
||||
peewee
|
||||
requests
|
||||
WTForms
|
||||
flask-session
|
||||
flask
|
||||
monero
|
||||
arrow
|
||||
flake8
|
Loading…
Reference in New Issue