Daniel Sheffield 1 год назад
Родитель
Сommit
8c91181d6e
5 измененных файлов с 143 добавлено и 15 удалено
  1. 11 0
      app/rest/hash_util.py
  2. 69 11
      app/rest/pyapi.py
  3. 6 0
      app/rest/templates/file-form.tpl
  4. 41 0
      app/rest/templates/upload.tpl
  5. 16 4
      app/rest/validate.py

+ 11 - 0
app/rest/hash_util.py

@@ -1,5 +1,6 @@
 from hashlib import blake2b, shake_128, md5, sha256, sha1
 from itertools import combinations
+import os
 from urllib.parse import urlencode
 import random
 import time
@@ -33,6 +34,16 @@ def blake(data: bytes, person: bytes = None) -> bytes:
         person=person
     ).digest()
 
+def blake_file(path: str, person: bytes = None, root: str ='app/rest/static') -> bytes:
+    fd = os.open(f'{root}/{path}', os.O_RDONLY, 0o600)
+    with open(fd, "rb") as f:
+        f.seek(0)
+        _blake = blake2b(usedforsecurity=False, digest_size=DIGEST_SIZE_BYTES, person='upload'.encode('utf-8'))
+        while f.peek(1):
+            _blake.update(f.read(1024))
+    return _blake.digest()
+        
+
 def python(data: str):
     return hash(data)
 

+ 69 - 11
app/rest/pyapi.py

@@ -3,20 +3,23 @@
 # All rights reserved
 #
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from hashlib import blake2b
+from io import BufferedRandom
 import os
 from threading import Thread
 from typing import Union
 from bottle import (
     route, request, response,
-    redirect,
+    redirect, abort,
     template, static_file,
     FormsDict, HTTPError,
 )
 from psycopg import Cursor, connect
 from psycopg.rows import TupleRow
+from uuid import uuid4
 
-from .validate import validate, validate_parameter, validate_url
-from .hash_util import blake, bytes_to_base32, normalize_base32
+from .validate import validate, validate_file, validate_parameter, validate_url
+from .hash_util import DIGEST_SIZE_BYTES, blake, bytes_to_base32, normalize_base32
 from .route_decorators import normalize, poison, cursor
 from .query_to_xml import get_categories, get_groups, get_products, get_tags
 from .CachedLoadingPage import CachedLoadingPage
@@ -110,6 +113,39 @@ def save(content: bytes, root='app/rest/static') -> str:
         f.write(content)
     return _b32
 
+def save_upload(content: BufferedRandom, root='app/rest/static') -> str:
+    tmpdir = '/tmp/upload'
+    try:
+        os.mkdir(tmpdir, mode=0o700, dir_fd=None)
+    except FileExistsError:
+        pass
+    unique = uuid4()
+    fd = os.open(f'{tmpdir}/{unique.hex}', os.O_WRONLY | os.O_TRUNC | os.O_CREAT, 0o600)
+    with open(fd, "wb") as f:
+        while content.peek(1):
+            seg = content.read(1024)
+            f.write(seg)
+    
+    fd = os.open(f'{tmpdir}/{unique.hex}', os.O_RDONLY, 0o600)
+    with open(fd, "rb") as f:
+        f.seek(0)
+        _blake = blake2b(usedforsecurity=False, digest_size=DIGEST_SIZE_BYTES, person='upload'.encode('utf-8'))
+        while f.peek(1):
+            _blake.update(f.read(1024))
+        
+        _bytes = _blake.digest()
+        _b32 = bytes_to_base32(_bytes)
+    
+    directory = f'{root}/{_b32}'
+    try:
+        os.mkdir(directory, mode=0o700, dir_fd=None)
+    except FileExistsError:
+        pass
+
+    os.replace(f'{tmpdir}/{unique.hex}', f'{directory}/{_b32}.file')
+    
+    return _b32
+
 
 @route('/clip', method=['GET', 'POST'])
 def clip():
@@ -161,10 +197,6 @@ def clip():
         )
 
 
-@route('/clip/', method='GET')
-def _clip(): return redirect(f'/clip')
-
-
 @route('/clip/<filename:path>', method='GET')
 def get_clip(filename):
     filename = filename and normalize_base32(filename)
@@ -179,6 +211,33 @@ def get_clip(filename):
     return static_file('/'.join([filename,]*2) + '.file', root='app/rest/static')
 
 
+@route('/upload', method=['GET', 'POST'])
+def upload():
+    if request.method == 'GET':
+        _hash = request.params.hash
+        if _hash:
+            _hash = normalize_base32(_hash)
+        
+        link = f'{LOCATION}/upload/{_hash}' if _hash else f'{LOCATION}/upload'
+        response.content_type = 'text/html; charset=utf-8'
+        form = template('file-form', action='/upload', method='post')
+        return template('upload', form=form, link=link)
+
+    if request.method == 'POST':
+        if 'paste' not in request.files:
+            return abort(400, "Parameter 'paste' must be specified")
+        
+        _b32 = save_upload(request.files['paste'].file)
+        return redirect(f'/upload?hash={_b32}')
+
+
+@route('/upload/<filename:path>', method='GET')
+def get_upload(filename):
+    filename = filename and normalize_base32(filename)
+
+    return validate_file(filename)
+
+
 @route('/goto', method=['GET', 'POST'])
 def goto():
     if request.method == 'GET':
@@ -214,11 +273,10 @@ def goto():
         return redirect(f'/goto?hash={_b32}')
 
 
-@route('/goto/', method='GET')
-def _goto(): return redirect(f'/goto')
-
-
 @route('/goto/<filename:path>', method='GET')
 def redirect_goto(filename):
     filename = filename and normalize_base32(filename)
     return redirect(f'/goto?hash={filename}&go=true')
+
+@route('/<any>/', method='GET')
+def redirect_trailing_slash(any): return redirect(f'/{any}')

+ 6 - 0
app/rest/templates/file-form.tpl

@@ -0,0 +1,6 @@
+% from app.data.filter import get_query_param
+% content = setdefault("content", "") or ""
+% disabled = (setdefault("disabled", False) and 'readonly="true"') or ""
+<form id="upload" method="{{ method }}" action="{{ action }}" enctype="multipart/form-data">
+  <input type="file" name="paste" />
+</form>

+ 41 - 0
app/rest/templates/upload.tpl

@@ -0,0 +1,41 @@
+% link = setdefault("link", "") or ""
+% disabled = setdefault("disabled", "") and 'disabled="true"'
+% download = setdefault("download", "") or ""
+% download_disabled = "" if download else 'disabled="true"'
+<!DOCTYPE html>
+<html>
+  <head>
+    <style>
+html {
+  --scrollbarBG: #333333;
+  --thumbBG: #080808;
+}
+body {
+  background-color: #080808;
+  color: #cccccc;
+}
+    </style>
+    <title>Upload</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1"/>
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@2.1.0/build/pure-min.css" integrity="sha384-yHIFVG6ClnONEA5yB5DJXfW2/KC173DIQrYoZMEtBvGzmf0PKiGyNEqe9N6BNDBH" crossorigin="anonymous"/>
+    <link rel="stylesheet" href="https://shandan.one/css/grids-responsive-min.css"/>
+    <link rel="stylesheet" href="https://shandan.one/css/responsive-visibility-collapse.css"/>
+  </head>
+  <body align="center" style="text-align: center">
+    <div class="pure-g">
+      % include('button-style')
+      <div class="pure-u-1">
+        <div class="pure-button-group" role="action" style="padding: 1em 0 0;">
+          <button class="button-resize pure-button" type="submit" form="upload" {{!disabled}}> Upload </button>
+        </div>
+      </div>
+      <div class="pure-u-1">
+        <div class="pure-button" style="margin: 1em 0 1em; background: #afaf0f;">
+          <a href="{{!link}}" style="color: floralwhite;">{{ link }}</a>
+        </div>
+      <div class="pure-u-1">
+{{!form}}
+      </div>
+    </div>
+  </body>
+</html>

+ 16 - 4
app/rest/validate.py

@@ -21,10 +21,10 @@
 """
 
 from io import BufferedReader
-from itertools import chain, zip_longest
-from bottle import static_file, HTTPError, abort, LocalRequest
-from urllib.parse import urlparse, quote, quote_plus, quote_from_bytes, urlencode
-from .hash_util import bytes_to_base32, blake
+from itertools import chain
+from bottle import static_file, response, HTTPError, abort, LocalRequest, HTTPResponse
+from urllib.parse import urlparse, quote, quote_plus
+from .hash_util import blake_file, bytes_to_base32, blake
 
 # according to rfc3696
 URL_MUST_ESCAPE = bytes([
@@ -57,6 +57,18 @@ def validate(filename: str) -> bytes:
         return abort(410, f"Paste content differs")
     return content
 
+def validate_file(filename: str) -> HTTPResponse:
+    # TODO: restore original filename
+    ret = static_file('/'.join([filename,]*2) + '.file', root='app/rest/static', download=filename)
+    if isinstance(ret, HTTPError):
+        return abort(404, f"No such upload: {filename}")
+
+    _bytes = blake_file('/'.join([filename,]*2) + '.file', person='upload'.encode('utf-8'), root='app/rest/static')
+    _b32 = bytes_to_base32(_bytes)
+    if _b32 != filename:
+        return abort(410, f"Uploaded content differs")
+    
+    return ret
 
 def validate_parameter(request: LocalRequest, name: str) -> bytes:
     if name not in request.params: