Added importer and ability to register accounts
This commit is contained in:
parent
506fefc1f0
commit
83e2c892b3
9 changed files with 204 additions and 78 deletions
124
app.py
124
app.py
|
|
@ -15,7 +15,7 @@ Application is kept compact, with all its core in a single file.
|
||||||
from flask import (
|
from flask import (
|
||||||
Flask, Markup, abort, flash, g, jsonify, make_response, redirect, request,
|
Flask, Markup, abort, flash, g, jsonify, make_response, redirect, request,
|
||||||
render_template, send_from_directory)
|
render_template, send_from_directory)
|
||||||
from flask_login import LoginManager, login_user, logout_user, current_user
|
from flask_login import LoginManager, login_user, logout_user, current_user, login_required
|
||||||
from flask_wtf import CSRFProtect
|
from flask_wtf import CSRFProtect
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
@ -39,6 +39,8 @@ FK = ForeignKeyField
|
||||||
|
|
||||||
SLUG_RE = r'[a-z0-9]+(?:-[a-z0-9]+)*'
|
SLUG_RE = r'[a-z0-9]+(?:-[a-z0-9]+)*'
|
||||||
ILINK_RE = r'\]\(/(p/\d+|' + SLUG_RE + ')/?\)'
|
ILINK_RE = r'\]\(/(p/\d+|' + SLUG_RE + ')/?\)'
|
||||||
|
USERNAME_RE = r'[a-z0-9_-]{3,30}'
|
||||||
|
PING_RE = r'(?<!\w)@(' + USERNAME_RE + r')'
|
||||||
|
|
||||||
#### GENERAL CONFIG ####
|
#### GENERAL CONFIG ####
|
||||||
|
|
||||||
|
|
@ -115,6 +117,11 @@ class SpoilerExtension(markdown.extensions.Extension):
|
||||||
def extendMarkdown(self, md, md_globals):
|
def extendMarkdown(self, md, md_globals):
|
||||||
md.inlinePatterns.register(markdown.inlinepatterns.SimpleTagInlineProcessor(r'()>!(.*?)!<', 'span class="spoiler"'), 'spoiler', 14)
|
md.inlinePatterns.register(markdown.inlinepatterns.SimpleTagInlineProcessor(r'()>!(.*?)!<', 'span class="spoiler"'), 'spoiler', 14)
|
||||||
|
|
||||||
|
|
||||||
|
#class PingExtension(markdown.extensions.Extension):
|
||||||
|
# def extendMarkdown(self, md):
|
||||||
|
# pass
|
||||||
|
|
||||||
#### DATABASE SCHEMA ####
|
#### DATABASE SCHEMA ####
|
||||||
|
|
||||||
database_url = _getconf('database', 'url')
|
database_url = _getconf('database', 'url')
|
||||||
|
|
@ -142,6 +149,17 @@ class User(BaseModel):
|
||||||
privileges = BitField()
|
privileges = BitField()
|
||||||
is_admin = privileges.flag(1)
|
is_admin = privileges.flag(1)
|
||||||
|
|
||||||
|
# helpers for flask_login
|
||||||
|
@property
|
||||||
|
def is_anonymous(self):
|
||||||
|
return False
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
return True
|
||||||
|
@property
|
||||||
|
def is_authenticated(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class Page(BaseModel):
|
class Page(BaseModel):
|
||||||
url = CharField(64, unique=True, null=True)
|
url = CharField(64, unique=True, null=True)
|
||||||
|
|
@ -208,7 +226,7 @@ class PageText(BaseModel):
|
||||||
else:
|
else:
|
||||||
return c.decode('latin-1')
|
return c.decode('latin-1')
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_content(cls, text, treshold=600, search_dup=True):
|
def create_content(cls, text, *, treshold=600, search_dup=True):
|
||||||
c = text.encode('utf-8')
|
c = text.encode('utf-8')
|
||||||
use_gzip = len(c) > treshold
|
use_gzip = len(c) > treshold
|
||||||
if use_gzip and gzip:
|
if use_gzip and gzip:
|
||||||
|
|
@ -387,6 +405,9 @@ def remove_tags(text, convert=True, headings=True):
|
||||||
text = md(text, toc=False, math=False)
|
text = md(text, toc=False, math=False)
|
||||||
return re.sub(r'<.*?>', '', text)
|
return re.sub(r'<.*?>', '', text)
|
||||||
|
|
||||||
|
def is_username(s):
|
||||||
|
return re.match('^' + USERNAME_RE + '$', s)
|
||||||
|
|
||||||
#### I18N ####
|
#### I18N ####
|
||||||
|
|
||||||
i18n.load_path.append(os.path.join(APP_BASE_DIR, 'i18n'))
|
i18n.load_path.append(os.path.join(APP_BASE_DIR, 'i18n'))
|
||||||
|
|
@ -421,6 +442,8 @@ app.url_map.converters['slug'] = SlugConverter
|
||||||
csrf = CSRFProtect(app)
|
csrf = CSRFProtect(app)
|
||||||
login_manager = LoginManager(app)
|
login_manager = LoginManager(app)
|
||||||
|
|
||||||
|
login_manager.login_view = 'accounts_login'
|
||||||
|
|
||||||
|
|
||||||
#### ROUTES ####
|
#### ROUTES ####
|
||||||
|
|
||||||
|
|
@ -801,6 +824,36 @@ def accounts_login():
|
||||||
return redirect(request.args.get('next', '/'))
|
return redirect(request.args.get('next', '/'))
|
||||||
return render_template('login.html')
|
return render_template('login.html')
|
||||||
|
|
||||||
|
@app.route('/accounts/register/', methods=['GET','POST'])
|
||||||
|
def accounts_register():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(request.args.get('next', '/'))
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = request.form['username']
|
||||||
|
password = request.form['password']
|
||||||
|
if not is_username(username):
|
||||||
|
flash('Invalid username: usernames can contain only letters, numbers, underscores and hyphens.')
|
||||||
|
return render_template('register.html')
|
||||||
|
if request.form['password'] != request.form['confirm_password']:
|
||||||
|
flash('Passwords do not match.')
|
||||||
|
return render_template('register.html')
|
||||||
|
if not request.form['legal']:
|
||||||
|
flash('You must accept Terms in order to register.')
|
||||||
|
try:
|
||||||
|
with database.atomic():
|
||||||
|
u = User.create(
|
||||||
|
username = username,
|
||||||
|
email = request.form.get('email'),
|
||||||
|
password = generate_password_hash(password),
|
||||||
|
join_date = datetime.datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
login_user(u)
|
||||||
|
return redirect(request.args.get('next', '/'))
|
||||||
|
except IntegrityError:
|
||||||
|
flash('Username taken')
|
||||||
|
return render_template('register.html')
|
||||||
|
|
||||||
@app.route('/accounts/logout/')
|
@app.route('/accounts/logout/')
|
||||||
def accounts_logout():
|
def accounts_logout():
|
||||||
logout_user()
|
logout_user()
|
||||||
|
|
@ -862,6 +915,10 @@ class Exporter(object):
|
||||||
pobj['title'] = p.title
|
pobj['title'] = p.title
|
||||||
pobj['url'] = p.url
|
pobj['url'] = p.url
|
||||||
pobj['tags'] = [tag.name for tag in p.tags]
|
pobj['tags'] = [tag.name for tag in p.tags]
|
||||||
|
pobj['calendar'] = p.calendar
|
||||||
|
pobj['flags'] = p.flags
|
||||||
|
if include_users:
|
||||||
|
pobj['owner'] = p.owner_id
|
||||||
hist = []
|
hist = []
|
||||||
for rev in (p.revisions if include_history else [p.latest]):
|
for rev in (p.revisions if include_history else [p.latest]):
|
||||||
revobj = {}
|
revobj = {}
|
||||||
|
|
@ -884,6 +941,58 @@ class Exporter(object):
|
||||||
def export(self):
|
def export(self):
|
||||||
return json.dumps(self.root)
|
return json.dumps(self.root)
|
||||||
|
|
||||||
|
class Importer(object):
|
||||||
|
def __init__(self, dump, *, overwrite_urls = True):
|
||||||
|
self.root = json.loads(dump)
|
||||||
|
self.owner = None
|
||||||
|
self.overwrite_urls = overwrite_urls
|
||||||
|
def claim(self, owner):
|
||||||
|
self.owner = owner
|
||||||
|
def execute(self):
|
||||||
|
no_pages = 0
|
||||||
|
no_revs = 0
|
||||||
|
for pobj in self.root['pages']:
|
||||||
|
purl = pobj.get("url")
|
||||||
|
try:
|
||||||
|
if purl:
|
||||||
|
try:
|
||||||
|
p2 = Page.get(Page.url == purl)
|
||||||
|
p2.url = None
|
||||||
|
p2.save()
|
||||||
|
except Page.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
p = Page.create(
|
||||||
|
url = purl if self.overwrite_urls else None,
|
||||||
|
title = pobj['title'],
|
||||||
|
calendar = pobj.get('calendar'),
|
||||||
|
owner = self.owner.id,
|
||||||
|
flags = pobj.get('flags'),
|
||||||
|
touched = datetime.datetime.now()
|
||||||
|
)
|
||||||
|
p.change_tags(pobj.get('tags'))
|
||||||
|
no_pages += 1
|
||||||
|
|
||||||
|
for revobj in pobj['history']:
|
||||||
|
textref = PageText.create_content(
|
||||||
|
revobj['text']
|
||||||
|
)
|
||||||
|
|
||||||
|
rev = PageRevision.create(
|
||||||
|
page = p,
|
||||||
|
user = self.owner.id,
|
||||||
|
textref = textref,
|
||||||
|
comment = revobj.get('comment'),
|
||||||
|
pub_date = datetime.datetime.fromtimestamp(revobj['timestamp']),
|
||||||
|
length = revobj['length']
|
||||||
|
)
|
||||||
|
no_revs += 1
|
||||||
|
except Exception as e:
|
||||||
|
sys.excepthook(*sys.exc_info())
|
||||||
|
continue
|
||||||
|
return no_pages, no_revs
|
||||||
|
|
||||||
|
|
||||||
@app.route('/manage/export/', methods=['GET', 'POST'])
|
@app.route('/manage/export/', methods=['GET', 'POST'])
|
||||||
def exportpages():
|
def exportpages():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
|
@ -914,7 +1023,18 @@ def exportpages():
|
||||||
return render_template('exportpages.html')
|
return render_template('exportpages.html')
|
||||||
|
|
||||||
@app.route('/manage/import/', methods=['GET', 'POST'])
|
@app.route('/manage/import/', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
def importpages():
|
def importpages():
|
||||||
|
if request.method == 'POST':
|
||||||
|
if current_user.is_admin:
|
||||||
|
f = request.files['import']
|
||||||
|
overwrite_urls = request.form.get('ovwurls')
|
||||||
|
im = Importer(f.read(), overwrite_urls=overwrite_urls)
|
||||||
|
im.claim(current_user)
|
||||||
|
res = im.execute()
|
||||||
|
flash('Imported successfully {} pages and {} revisions'.format(*res))
|
||||||
|
else:
|
||||||
|
flash('Pages can be imported by Administrators only!')
|
||||||
return render_template('importpages.html')
|
return render_template('importpages.html')
|
||||||
|
|
||||||
#### EXTENSIONS ####
|
#### EXTENSIONS ####
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
from flask import Blueprint, render_template, request
|
|
||||||
import json, datetime
|
|
||||||
|
|
||||||
from app import Page, PageTag
|
|
||||||
|
|
||||||
bp = Blueprint('importexport', __name__)
|
|
||||||
|
|
||||||
class Exporter(object):
|
|
||||||
def __init__(self):
|
|
||||||
self.root = {'pages': [], 'users': {}}
|
|
||||||
def add_page(self, p, include_history=True, include_users=False):
|
|
||||||
pobj = {}
|
|
||||||
pobj['title'] = p.title
|
|
||||||
pobj['url'] = p.url
|
|
||||||
pobj['tags'] = [tag.name for tag in p.tags]
|
|
||||||
hist = []
|
|
||||||
for rev in (p.revisions if include_history else [p.latest]):
|
|
||||||
revobj = {}
|
|
||||||
revobj['text'] = rev.text
|
|
||||||
revobj['timestamp'] = rev.pub_date.timestamp()
|
|
||||||
if include_users:
|
|
||||||
revobj['user'] = rev.user_id
|
|
||||||
if rev.user_id not in self.root['users']:
|
|
||||||
self.root['users'][rev.user_id] = rev.user_info()
|
|
||||||
else:
|
|
||||||
revobj['user'] = None
|
|
||||||
revobj['comment'] = rev.comment
|
|
||||||
revobj['length'] = rev.length
|
|
||||||
hist.append(revobj)
|
|
||||||
pobj['history'] = hist
|
|
||||||
self.root['pages'].append(pobj)
|
|
||||||
def add_page_list(self, pl, include_history=True, include_users=False):
|
|
||||||
for p in pl:
|
|
||||||
self.add_page(p, include_history=include_history, include_users=include_users)
|
|
||||||
def export(self):
|
|
||||||
return json.dumps(self.root)
|
|
||||||
|
|
||||||
@bp.route('/manage/export/', methods=['GET', 'POST'])
|
|
||||||
def exportpages():
|
|
||||||
if request.method == 'POST':
|
|
||||||
raw_list = request.form['export-list']
|
|
||||||
q_list = []
|
|
||||||
for item in raw_list.split('\n'):
|
|
||||||
item = item.strip()
|
|
||||||
if len(item) < 2:
|
|
||||||
continue
|
|
||||||
if item.startswith('+'):
|
|
||||||
q_list.append(Page.select().where(Page.id == item[1:]))
|
|
||||||
elif item.startswith('#'):
|
|
||||||
q_list.append(Page.select().join(PageTag, on=PageTag.page).where(PageTag.name == item[1:]))
|
|
||||||
elif item.startswith('/'):
|
|
||||||
q_list.append(Page.select().where(Page.url == item[1:].rstrip('/')))
|
|
||||||
else:
|
|
||||||
q_list.append(Page.select().where(Page.title == item))
|
|
||||||
if not q_list:
|
|
||||||
flash('Failed to export pages: The list is empty!')
|
|
||||||
return render_template('exportpages.html')
|
|
||||||
query = q_list.pop(0)
|
|
||||||
while q_list:
|
|
||||||
query |= q_list.pop(0)
|
|
||||||
e = Exporter()
|
|
||||||
e.add_page_list(query)
|
|
||||||
return e.export(), {'Content-Type': 'application/json', 'Content-Disposition': 'attachment; ' +
|
|
||||||
'filename=export-{}.json'.format(datetime.datetime.now().strftime('%Y%m%d-%H%M%S'))}
|
|
||||||
return render_template('exportpages.html')
|
|
||||||
|
|
||||||
@bp.route('/manage/import/', methods=['GET', 'POST'])
|
|
||||||
def importpages():
|
|
||||||
return render_template('importpages.html')
|
|
||||||
|
|
||||||
|
|
@ -45,6 +45,15 @@
|
||||||
"notes-count-with-url": "Number of pages with URL set",
|
"notes-count-with-url": "Number of pages with URL set",
|
||||||
"revision-count": "Number of revisions",
|
"revision-count": "Number of revisions",
|
||||||
"revision-count-per-page": "Average revisions per page",
|
"revision-count-per-page": "Average revisions per page",
|
||||||
"remember-me-for": "Remember me for"
|
"remember-me-for": "Remember me for",
|
||||||
|
"confirm-password": "Confirm password",
|
||||||
|
"email": "E-mail",
|
||||||
|
"optional": "optional",
|
||||||
|
"have-read-terms": "I have read {0} and {1}",
|
||||||
|
"terms-of-service": "Terms of Service",
|
||||||
|
"privacy-policy": "Privacy Policy",
|
||||||
|
"already-have-account": "Already have an account?",
|
||||||
|
"logged-in-as": "Logged in as",
|
||||||
|
"not-logged-in": "Not logged in"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -35,8 +35,16 @@
|
||||||
"login": "Entra",
|
"login": "Entra",
|
||||||
"username": "Nome utente",
|
"username": "Nome utente",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
|
"no-account-sign-up": "Non hai un account?",
|
||||||
|
"sign-up": "Registrati",
|
||||||
"not-found": "Non trovato",
|
"not-found": "Non trovato",
|
||||||
"not-found-text-1": "La pagina con url",
|
"not-found-text-1": "La pagina con url",
|
||||||
"not-found-text-2": "non esiste"
|
"not-found-text-2": "non esiste",
|
||||||
|
"users-count": "Numero di utenti",
|
||||||
|
"notes-count": "Numero di note",
|
||||||
|
"notes-count-with-url": "Numero di note con URL impostato",
|
||||||
|
"revision-count": "Numero di revisioni",
|
||||||
|
"revision-count-per-page": "Media di revisioni per pagina",
|
||||||
|
"remember-me-for": "Ricordami per"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from playhouse.migrate import migrate, SqliteMigrator, MySQLMigrator
|
from playhouse.migrate import migrate, SqliteMigrator, MySQLMigrator
|
||||||
from peewee import MySQLDatabase, SqliteDatabase, \
|
from peewee import MySQLDatabase, SqliteDatabase, \
|
||||||
IntegerField, DateTimeField, ForeignKeyField
|
IntegerField, DateTimeField, ForeignKeyField, DeferredForeignKey
|
||||||
from app import database, User
|
from app import database, User
|
||||||
|
|
||||||
if type(database) == MySQLDatabase:
|
if type(database) == MySQLDatabase:
|
||||||
|
|
@ -15,6 +15,6 @@ with database.atomic():
|
||||||
database.create_tables([User])
|
database.create_tables([User])
|
||||||
migrate(
|
migrate(
|
||||||
migrator.add_column('page', 'calendar', DateTimeField(index=True, null=True)),
|
migrator.add_column('page', 'calendar', DateTimeField(index=True, null=True)),
|
||||||
migrator.add_column('page', 'owner_id', IntegerField(null=True))
|
migrator.add_column('page', 'owner', DeferredForeignKey('User', null=True))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@
|
||||||
<li><a href="/accounts/login/" title="{{ T('login') }}" rel="nofollow"><span class="material-icons">login</span></a></li>
|
<li><a href="/accounts/login/" title="{{ T('login') }}" rel="nofollow"><span class="material-icons">login</span></a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<div class="footer-copyright">© 2020–2022 Sakuragasaki46.</div>
|
<div class="footer-copyright">© 2020–2023 Sakuragasaki46.</div>
|
||||||
|
<div class="footer-loggedinas">{% if current_user %}{{ T('logged-in-as') }}: <strong>{{ current_user.username }}</strong>{% else %}{{ T('not-logged-in') }}{% endif %}</div>
|
||||||
<div class="footer-actions" id="page-actions">{% block actions %}{% endblock %}</div>
|
<div class="footer-actions" id="page-actions">{% block actions %}{% endblock %}</div>
|
||||||
<div class="footer-versions">{{app_name}} version {{app_version}}</div>
|
<div class="footer-versions">{{app_name}} version {{app_version}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,27 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Import pages</h1>
|
<h1>Import pages</h1>
|
||||||
|
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<p>
|
||||||
|
You can import files produced by the <a href="/manage/export">exporter tool</a>, in JSON format.
|
||||||
|
Importing pages can be done by users with Admin permissions only.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<form enctype="multipart/form-data" method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<div>
|
||||||
|
<input type="file" accept="application/json" name="import" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="checkbox" name="ovwurls" value="1" checked="" />
|
||||||
|
<label for="ovwurls">Overwrite URLs</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="submit" value="Import" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
<p>Importing pages can be done by users with Admin permissions only.</p>
|
<p>Importing pages can be done by users with Admin permissions only.</p>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -29,5 +29,5 @@
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p>{{ T('no-account-sign-up') }} <a href="/accounts/signup" rel="nofollow">{{ T("sign-up") }}</a></p>
|
<p>{{ T('no-account-sign-up') }} <a href="/accounts/register" rel="nofollow">{{ T("sign-up") }}</a></p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
36
templates/register.html
Normal file
36
templates/register.html
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ T('sign-up') }} – {{ app_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ T('sign-up') }}</h1>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<div>
|
||||||
|
<label>{{ T('username') }}:</label>
|
||||||
|
<input type="text" name="username" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>{{ T('password') }}:</label>
|
||||||
|
<input type="password" name="password" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>{{ T('confirm-password') }}:</label>
|
||||||
|
<input type="password" name="confirmpassword" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>{{ T('email') }} ({{ T('optional') }}):</label>
|
||||||
|
<input type="email" name="email" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="checkbox" name="legal" value="1" />
|
||||||
|
<label>{{ T('have-read-terms').format(T('terms-of-service'), T('privacy-policy')) }}</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="submit" value="{{ T('login') }}" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p>{{ T('already-have-account') }} <a href="/accounts/login" rel="nofollow">{{ T("login") }}</a></p>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue