Added importer and ability to register accounts

This commit is contained in:
Yusur 2023-01-05 11:46:54 +01:00
parent 506fefc1f0
commit 83e2c892b3
9 changed files with 204 additions and 78 deletions

124
app.py
View file

@ -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 ####

View file

@ -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')

View file

@ -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"
} }
} }

View file

@ -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"
} }
} }

View file

@ -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))
) )

View file

@ -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">&copy; 20202022 Sakuragasaki46.</div> <div class="footer-copyright">&copy; 20202023 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>

View file

@ -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 %}

View file

@ -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
View 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 %}