Merge pull request #240 from OGAWAHirofumi/add-edit

Add edit dialog to modify metadata (for now)
master
TW 2020-11-11 04:38:09 +01:00 committed by GitHub
commit 21ca683ec8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 307 additions and 31 deletions

View File

@ -17,6 +17,7 @@ passphrases) as credentials - there are no separate user names.
The site admin can assign permissions to login credentials (and also to the anonymous, not logged-in user):
* create: be able to create pastebins
* modify: be able to modify pastebins
* read: be able to read / download pastebins
* delete: be able to delete pastebins
* list: be able to list (discover) all pastebins

View File

@ -42,7 +42,7 @@ setup(
'Pygments',
'xstatic',
'XStatic-asciinema-player',
'xstatic-bootbox',
'xstatic-bootbox>=5.4.0',
'xstatic-bootstrap>=4.0.0.0,<5.0.0.0',
'xstatic-font-awesome',
'xstatic-jquery',

View File

@ -1,8 +1,8 @@
from flask import Blueprint
from .lodgeit import LodgeitUpload
from .rest import ItemDetailView, ItemDownloadView, ItemUploadView, InfoView, \
ItemDeleteView, ItemLockView, ItemUnlockView
from .rest import ItemDetailView, ItemDownloadView, ItemModifyView, \
ItemUploadView, InfoView, ItemDeleteView, ItemLockView, ItemUnlockView
blueprint = Blueprint('bepasty_apis', __name__, url_prefix='/apis')
@ -13,5 +13,6 @@ blueprint.add_url_rule('/rest/items', view_func=ItemUploadView.as_view('items'))
blueprint.add_url_rule('/rest/items/<itemname:name>', view_func=ItemDetailView.as_view('items_detail'))
blueprint.add_url_rule('/rest/items/<itemname:name>/download', view_func=ItemDownloadView.as_view('items_download'))
blueprint.add_url_rule('/rest/items/<itemname:name>/delete', view_func=ItemDeleteView.as_view('items_delete'))
blueprint.add_url_rule('/rest/items/<itemname:name>/modify', view_func=ItemModifyView.as_view('items_modify'))
blueprint.add_url_rule('/rest/items/<itemname:name>/lock', view_func=ItemLockView.as_view('items_lock'))
blueprint.add_url_rule('/rest/items/<itemname:name>/unlock', view_func=ItemUnlockView.as_view('items_unlock'))

View File

@ -17,6 +17,7 @@ from ..utils.upload import Upload, filter_internal, background_compute_hash
from ..views.filelist import file_infos
from ..views.delete import DeleteView
from ..views.download import DownloadView
from ..views.modify import ModifyView
from ..views.setkv import LockView, UnlockView
@ -248,6 +249,28 @@ class ItemDownloadView(ItemDetailView):
return super(ItemDetailView, self).get(name)
class ItemModifyView(ModifyView, RestBase):
def error(self, item, error):
raise Conflict(description=error)
def response(self, name):
return make_response('{}', {'Content-Type': 'application/json'})
def get_params(self):
json = request.json
if json is None:
raise BadRequest(description='Content-Type or JSON format is invalid')
return {
FILENAME: json.get(FILENAME),
TYPE: json.get(TYPE),
}
@rest_errorhandler
def post(self, name):
return super(ItemModifyView, self).post(name)
class ItemDeleteView(DeleteView, RestBase):
def error(self, item, error):
raise Conflict(description=error)

View File

@ -18,6 +18,7 @@ from .utils.permissions import (
ADMIN,
CREATE,
DELETE,
MODIFY,
LIST,
READ,
get_permission_icons,
@ -142,6 +143,7 @@ def create_app():
app.jinja_env.globals['ADMIN'] = ADMIN
app.jinja_env.globals['LIST'] = LIST
app.jinja_env.globals['CREATE'] = CREATE
app.jinja_env.globals['MODIFY'] = MODIFY
app.jinja_env.globals['READ'] = READ
app.jinja_env.globals['DELETE'] = DELETE

View File

@ -123,12 +123,12 @@ class Config(object):
#: ::
#:
#: PERMISSIONS = {
#: 'myadminsecret_1.21d-3!wdar34': 'admin,list,create,read,delete',
#: 'myadminsecret_1.21d-3!wdar34': 'admin,list,create,modify,read,delete',
#: 'uploadersecret_rtghtrbrrrfsd': 'create,read',
#: 'joe_doe_89359299887711335537': 'create,read,delete',
#: }
PERMISSIONS = {
# 'foo': 'admin,list,create,read,delete',
# 'foo': 'admin,list,create,modify,read,delete',
}
#: not-logged-in users get these permissions -

View File

@ -131,3 +131,8 @@ table.highlighttable {
.linenos {
width: 1px;
}
/* autocomplete for modify dialog */
.ui-autocomplete {
z-index: 1065;
}

View File

@ -8,6 +8,37 @@ $(function () {
});
});
// Show a modify dialog box when trying to edit metadata.
$("#modify-btn").click(function() {
var form_name = "modify-frm"
var hidden_name_id = "#hidden-" + form_name
// Read form template from html
var modal_form = $(hidden_name_id).html();
var modal_title = $(hidden_name_id).attr('modalTitle');
var modal_focus = $(hidden_name_id).attr('modalFocus');
// A bit of a hack to avoid implementing this from scratch
// using .dialog().
var box = bootbox.confirm({
title: modal_title,
message: modal_form,
centerVertical: true,
onShown: function(e) {
if (modal_focus) {
$(this).find("#" + modal_focus).trigger('focus');
}
// Support jquery-ui autocomplete
contenttype_autocomplete(this)
},
callback: function(result) {
// Please note that this is not called when hitting
// the Enter key on the input box.
if (result == true) {
$(this).find("#" + form_name).submit();
}
}
});
});
// Bind on click event to all line number anchor tags
$('td.linenos a').on('click', function(e) {
remove_highlights();

View File

@ -80,3 +80,16 @@
</tbody>
</table>
{% endmacro %}
{% macro input_filename(value) -%}
<input class="form-control" type="text" id="filename" name="filename" size="40" placeholder="optional download-filename"{% if value is defined %} value="{{ value }}"{% endif %}>
{%- endmacro %}
{% macro input_contenttype(value) -%}
<input class="form-control" type="text" id="contenttype" name="contenttype" size="30" placeholder="Content-Type"{% if value is defined %} value="{{ value }}"{% endif %}>
{%- endmacro %}
{% macro contenttype_autocomplete(selector, contenttypes) -%}
var availableTypes = ["{{ contenttypes | join('","') | safe}}"];
{{ selector|safe }}.autocomplete({source: availableTypes});
{%- endmacro %}

View File

@ -1,5 +1,7 @@
{% extends "_layout.html" %}
{%- import '_utils.html' as utils -%}
{% block content %}
<div class="card">
<div class="card-header">
@ -18,6 +20,29 @@
<span class="fa fa-asterisk"></span> Inline
</a>
{% endif %}
{% if may(MODIFY) %}
<div id="hidden-modify-frm" class="d-none" modalTitle="Modify Metadata" modalFocus="filename">
<!-- modify form that used by utils.js -->
<form id="modify-frm" action="{{ url_for('bepasty.modify', name=name) }}" method="post">
<div class="form-group row">
<label for="filename" class="col-2 form-label">Filename</label>
<div class="col-10">
{{ utils.input_filename(item.meta['filename']) }}
</div>
</div>
<div class="form-group row">
<label for="contenttype" class="col-2 form-label">Type</label>
<div class="col-10">
{{ utils.input_contenttype(item.meta['type']) }}
</div>
</div>
<button type="submit" class="btn btn-primary d-none">Submit</button>
</form>
</div>
<button id="modify-btn" type="button" class="btn btn-info">
<span class="fa fa-edit"></span> Modify
</button>
{% endif %}
{% if may(DELETE) %}
<form id="del-frm" action="{{ url_for('bepasty.delete', name=name) }}" method="post" class="btn-group">
<input type="hidden" name="next" value="{{ url_for('bepasty.index') }}">
@ -76,4 +101,12 @@
<script src="{{ url_for('bepasty.xstatic', name='bootbox', filename='bootbox.min.js') }}" type="text/javascript"></script>
<script src="{{ url_for('bepasty.xstatic', name='asciinema_player', filename='asciinema-player.js') }}" type="text/javascript"></script>
<script src="{{ url_for('static', filename='app/js/utils.js') }}" type="text/javascript"></script>
{% if may(MODIFY) %}
<script>
<!-- function that used by utils.js -->
function contenttype_autocomplete(modal_box) {
{{ utils.contenttype_autocomplete('$(modal_box).find("#contenttype")', contenttypes) }}
};
</script>
{% endif %}
{% endblock extra_script %}

View File

@ -1,5 +1,7 @@
{% extends "_layout.html" %}
{%- import '_utils.html' as utils %}
{% macro maximum_lifetime() -%}
<div class="row">
<div class="col-lg-3 form-group">
@ -29,10 +31,10 @@
</div>
<div class="row">
<div class="col-3 form-group">
<input class="form-control" type="text" id="contenttype" name="contenttype" size="30" placeholder="Content-Type">
{{ utils.input_contenttype() }}
</div>
<div class="col-6 form-group">
<input class="form-control" type="text" id="filename" name="filename" size="40" placeholder="optional download-filename">
{{ utils.input_filename() }}
</div>
<div class="col-3">
<button id="formupload-submit" class="btn btn-primary btn-block">Submit</button>
@ -148,8 +150,7 @@
<script>
$(function() {
var availableTypes = ["{{ contenttypes | join('","') | safe}}"];
$("#contenttype").autocomplete({source: availableTypes});
{{ utils.contenttype_autocomplete('$("#contenttype")', contenttypes) }}
});
</script>
{% endblock %}

View File

@ -17,6 +17,7 @@ class TestScreenShots(object):
# bootstrap4 breakpoints
screenshot_dir = 'screenshots'
screen_sizes = [(450, 700), (576, 800), (768, 600), (992, 768), (1200, 1024)]
screenshot_seq = 1
def setup_class(self):
"""
@ -39,7 +40,8 @@ class TestScreenShots(object):
if not os.path.isdir(self.screenshot_dir):
os.mkdir(self.screenshot_dir)
self.browser.save_screenshot(
'{}/{}-{}x{}.png'.format(self.screenshot_dir, name, w, h)
'{}/{:02d}-{}-{}x{}.png'.format(self.screenshot_dir,
self.screenshot_seq, name, w, h)
)
def screen_shots(self, name):
@ -89,13 +91,15 @@ class TestScreenShots(object):
def error_404(self):
# NOTE: 404 error
self.screen_shots("01-error404")
self.screen_shots("error404")
self.screenshot_seq += 1
def login(self):
self.browser.get(self.url_base)
# NOTE: login screen, 1 - close hamburger, 2 - open hamburger
self.top_screen_shots("02-top")
self.top_screen_shots("top")
self.screenshot_seq += 1
token = self.browser.find_element_by_name("token")
password = "foo"
@ -105,7 +109,8 @@ class TestScreenShots(object):
self.wait_present("//input[@value='Logout']")
# NOTE: upload screen, 1 - close hamburger, 2 - open hamburger
self.top_screen_shots("03-upload")
self.top_screen_shots("upload")
self.screenshot_seq += 1
try:
self.browser.find_element_by_xpath("//input[@value='Logout']")
@ -141,7 +146,7 @@ echo "hello, world!"
self.scroll_to_bottom()
# NOTE: uploaded screen
self.screen_shots("04-uploading1")
self.screen_shots("uploading1")
# big file
with tempfile.NamedTemporaryFile(suffix=".bin") as fp:
@ -151,7 +156,8 @@ echo "hello, world!"
self.scroll_to_bottom()
# NOTE: in-progress uploading screen
self.screen_shots("05-uploading2")
self.screen_shots("uploading2")
self.screenshot_seq += 1
# click abort
abort = self.browser.find_element_by_id('fileupload-abort')
@ -159,7 +165,8 @@ echo "hello, world!"
time.sleep(.5)
# NOTE: abort bootbox
self.screen_shots("06-abort")
self.screen_shots("abort")
self.screenshot_seq += 1
ok = self.browser.find_element_by_class_name('bootbox-accept')
ok.click()
@ -167,12 +174,14 @@ echo "hello, world!"
self.scroll_to_bottom()
# NOTE: aborted upload screen
self.screen_shots("07-uploading3")
self.screen_shots("uploading3")
self.screenshot_seq += 1
def list_view(self):
self.browser.get(self.url_base + '/+list')
# NOTE: list screen
self.screen_shots("08-list")
self.screen_shots("list")
self.screenshot_seq += 1
def display_view(self):
self.browser.get(self.url_base + '/+list')
@ -183,17 +192,32 @@ echo "hello, world!"
self.browser.get(self.browser.current_url + '#L-4')
# NOTE: display screen
self.screen_shots("09-display")
self.screen_shots("display")
self.screenshot_seq += 1
modify = self.browser.find_element_by_id('modify-btn')
modify.click()
time.sleep(.5)
# NOTE: modify bootbox
self.screen_shots("modify")
self.screenshot_seq += 1
modify_cancel = self.browser.find_element_by_class_name('bootbox-cancel')
modify_cancel.click()
time.sleep(.5)
lock = self.browser.find_element_by_id('lock-btn')
lock.click()
# NOTE: display with lock screen
self.screen_shots("10-lock")
self.screen_shots("lock")
self.screenshot_seq += 1
qr = self.browser.find_element_by_id('qr-btn')
qr.click()
# NOTE: QR code screen
self.screen_shots("11-qr")
self.screen_shots("qr")
self.screenshot_seq += 1
def test(self):
self.error_404()

View File

@ -10,8 +10,7 @@ import copy
import hashlib
import re
from requests.auth import _basic_auth_str
from flask import current_app
from flask import url_for
from flask import current_app, url_for, json
import pytest
@ -19,7 +18,7 @@ from ..app import create_app
from ..config import Config
from ..constants import FILENAME, TYPE, LOCKED, SIZE, COMPLETE, HASH, \
TIMESTAMP_DOWNLOAD, TIMESTAMP_UPLOAD, TIMESTAMP_MAX_LIFE, TRANSACTION_ID
from ..utils.date_funcs import time_unit_to_sec
from ..utils.date_funcs import get_maxlife
UPLOAD_DATA = b"""\
#!/usr/bin/python3
@ -66,8 +65,8 @@ def wait_background():
def client_fixture(tmp_path):
with FakeTime() as faketime:
Config.PERMISSIONS = {
'admin': 'admin,list,create,read,delete',
'full': 'list,create,read,delete',
'admin': 'admin,list,create,modify,read,delete',
'full': 'list,create,modify,read,delete',
'none': '',
}
Config.STORAGE_FILESYSTEM_DIRECTORY = str(tmp_path)
@ -128,6 +127,11 @@ class RestUrl:
with current_app.test_request_context():
return url_for('bepasty_apis.items_delete', name=self.item_id)
@property
def modify(self):
with current_app.test_request_context():
return url_for('bepasty_apis.items_modify', name=self.item_id)
@property
def lock(self):
with current_app.test_request_context():
@ -286,7 +290,12 @@ def make_meta(data, filename=None, ftype=None, lifetime=None, uri=None):
if lifetime is None:
# default maxlife is 1 MONTHS
lifetime = [1, 'MONTHS']
maxlife = int(time.time()) + time_unit_to_sec(lifetime[0], lifetime[1])
maxtime = get_maxlife({
'maxlife_value': lifetime[0],
'maxlife_unit': lifetime[1]
}, True)
maxlife = int(time.time()) + maxtime if maxtime > 0 else maxtime
meta = {
'file-meta': {
@ -800,6 +809,56 @@ def test_download_range(client_fixture):
total_size=len(data))
def test_modify(client_fixture):
app, client, _ = client_fixture
meta = upload(client, UPLOAD_DATA, token='full', filename='test.py',
ftype='text/x-python', lifetime=[1, 'FOREVER'])
item_id = os.path.basename(meta['uri'])
url = RestUrl(item_id)
check_detail_or_download(app, client, item_id, meta, None)
headers = {'Content-Type': 'application/json'}
# no permission
response = client.post(url.modify, headers=headers, data='{}')
check_err_response(response, 403)
headers = add_auth('user', 'full', headers)
# invalid name
response = client.post(RestUrl('abcdefgh').modify, headers=headers, data='{}')
check_err_response(response, 404)
# invalid Content-Type
response = client.post(url.modify, headers=add_auth('user', 'full'), data='{}')
check_err_response(response, 400)
# invalid json
response = client.post(url.modify, headers=headers, data='')
check_err_response(response, 400)
# change filename
filename = 'test2.py'
meta['file-meta'][FILENAME] = filename
data = json.dumps({FILENAME: filename})
response = client.post(url.modify, headers=headers, data=data)
check_json_response(response, {})
check_detail_or_download(app, client, item_id, meta, None)
# change type
content_type = 'text/plain'
meta['file-meta'][TYPE] = content_type
data = json.dumps({TYPE: content_type})
response = client.post(url.modify, headers=headers, data=data)
check_json_response(response, {})
check_detail_or_download(app, client, item_id, meta, None)
def test_delete_basic(client_fixture):
app, client, faketime = client_fixture
@ -871,6 +930,16 @@ def test_lock_basic(client_fixture):
response = client.get(url.download, headers=add_auth('user', 'admin'))
check_data_response(response, metas[item_id], datas[item_id])
# modify locked item (should fail)
headers = add_auth('user', 'full', {'Content-Type': 'application/json'})
response = client.post(url.modify, headers=headers, data='{}')
check_err_response(response, 403)
# modify locked item with admin (should succeed)
headers = add_auth('user', 'admin', {'Content-Type': 'application/json'})
response = client.post(url.modify, headers=headers, data='{}')
check_json_response(response, {})
# delete locked item (should fail)
response = client.post(url.delete, headers=add_auth('user', 'full'))
check_err_response(response, 403)
@ -918,6 +987,11 @@ def test_incomplete(client_fixture):
response = client.get(url.download, headers=add_auth('user', 'full'))
check_err_response(response, 409)
# modify should error with incomplete
headers = add_auth('user', 'full', {'Content-Type': 'application/json'})
response = client.post(url.modify, headers=headers, data='{}')
check_err_response(response, 409)
# lock should error with incomplete
response = client.post(url.lock, headers=add_auth('user', 'admin'))
check_err_response(response, 409)

View File

@ -67,7 +67,7 @@ class TestMaxlifeFeature(object):
def delete_current_file(self):
self.browser.find_element_by_id("del-btn").click()
time.sleep(.2)
self.browser.find_element_by_class_name("btn-primary").click()
self.browser.find_element_by_class_name("bootbox-accept").click()
def test_paste_keep_forever(self):
self.browser.find_element_by_xpath("//select[@name='maxlife-unit']/option[@value='forever']").click()

View File

@ -6,6 +6,7 @@ from flask import g as flaskg
ADMIN = 'admin'
LIST = 'list'
CREATE = 'create'
MODIFY = 'modify'
READ = 'read'
DELETE = 'delete'
@ -17,6 +18,7 @@ permission_icons = {
'admin': 'user',
'list': 'list',
'create': 'plus',
'modify': 'edit',
'read': 'book',
'delete': 'trash'
}

View File

@ -3,6 +3,7 @@ from flask import Blueprint
from .delete import DeleteView
from .display import DisplayView
from .download import DownloadView, InlineView
from .modify import ModifyView
from .qr import QRView
from .filelist import FileListView
from .index import index
@ -24,6 +25,7 @@ blueprint.add_url_rule('/<itemname:name>', view_func=DisplayView.as_view('displa
blueprint.add_url_rule('/<itemname:name>/+delete', view_func=DeleteView.as_view('delete'))
blueprint.add_url_rule('/<itemname:name>/+download', view_func=DownloadView.as_view('download'))
blueprint.add_url_rule('/<itemname:name>/+inline', view_func=InlineView.as_view('inline'))
blueprint.add_url_rule('/<itemname:name>/+modify', view_func=ModifyView.as_view('modify'))
blueprint.add_url_rule('/<itemname:name>/+qr', view_func=QRView.as_view('qr'))
blueprint.add_url_rule('/<itemname:name>/+lock', view_func=LockView.as_view('lock'))
blueprint.add_url_rule('/<itemname:name>/+unlock', view_func=UnlockView.as_view('unlock'))

View File

@ -14,6 +14,7 @@ from ..utils.date_funcs import delete_if_lifetime_over
from ..utils.formatters import CustomHtmlFormatter
from ..utils.permissions import ADMIN, READ, may
from .index import contenttypes_list
from .filelist import file_infos
@ -136,4 +137,5 @@ class DisplayView(MethodView):
rendered_content = u"Rendering not allowed (too big?). Try download"
return render_template('display.html', name=name, item=item,
rendered_content=rendered_content)
rendered_content=rendered_content,
contenttypes=contenttypes_list())

View File

@ -3,10 +3,14 @@ from flask import render_template
from pygments.lexers import get_all_lexers
def index():
def contenttypes_list():
contenttypes = [
'text/x-bepasty-redirect', # redirect / link shortener service
]
for lexer_info in get_all_lexers():
contenttypes.extend(lexer_info[3])
return render_template('index.html', contenttypes=contenttypes)
return contenttypes
def index():
return render_template('index.html', contenttypes=contenttypes_list())

View File

@ -0,0 +1,58 @@
import errno
from flask import current_app, request, render_template
from flask.views import MethodView
from werkzeug.exceptions import Forbidden, NotFound
from ..constants import COMPLETE, FILENAME, LOCKED, TYPE
from ..utils.date_funcs import delete_if_lifetime_over
from ..utils.http import redirect_next_referrer
from ..utils.permissions import ADMIN, CREATE, may
from ..utils.upload import Upload
class ModifyView(MethodView):
def error(self, item, error):
return render_template('error.html', heading=item.meta[FILENAME], body=error), 409
def response(self, name):
return redirect_next_referrer('bepasty.display', name=name)
def get_params(self):
return {
FILENAME: request.form.get('filename'),
TYPE: request.form.get('contenttype'),
}
def post(self, name):
if not may(CREATE):
raise Forbidden()
try:
with current_app.storage.openwrite(name) as item:
if not item.meta[COMPLETE] and not may(ADMIN):
error = 'Upload incomplete. Try again later.'
return self.error(item, error)
if item.meta[LOCKED] and not may(ADMIN):
raise Forbidden()
if delete_if_lifetime_over(item, name):
raise NotFound()
params = self.get_params()
if params[FILENAME]:
item.meta[FILENAME] = Upload.filter_filename(
params[FILENAME], name, params[TYPE], item.meta[TYPE]
)
if params[TYPE]:
item.meta[TYPE], _ = Upload.filter_type(
params[TYPE], item.meta[TYPE]
)
return self.response(name)
except (OSError, IOError) as e:
if e.errno == errno.ENOENT:
raise NotFound()
raise