import base64
import datetime
import logging
import os
import re
import stat
import tempfile
import zipfile
from os.path import normpath
from wsgiref.util import FileWrapper
import pysftp
from Crypto import Random
from Crypto.Cipher import AES
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import StreamingHttpResponse
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.text import slugify
from django.views.decorators.debug import sensitive_post_parameters, sensitive_variables
from paramiko import SFTPError, SSHException
from ..auth.decorators import deny_restricted
from .forms import UploadFileForm
from .models import Host
logger = logging.getLogger(__name__)
exceptions = (EOFError, OSError, PermissionError, SSHException, SFTPError)
[docs]@sensitive_variables("password")
def create_session(hostname, username, password):
cnopts = pysftp.CnOpts()
cnopts.hostkeys = None
return pysftp.Connection(hostname, username=username, password=password, cnopts=cnopts)
@login_required
@deny_restricted
def files_view(request):
"""The main filecenter view."""
hosts = Host.objects.visible_to_user(request.user)
context = {"hosts": hosts}
return render(request, "files/home.html", context)
@login_required
@deny_restricted
@sensitive_variables("message", "key", "iv", "ciphertext")
@sensitive_post_parameters("password")
def files_auth(request):
"""Display authentication for filecenter."""
if "password" in request.POST:
"""
Encrypt the password with AES mode CFB.
Create a random 32 char key, stored in a CLIENT-side cookie.
Create a random 32 char IV, stored in a SERVER-side session.
Store the encrypted ciphertext in a SERVER-side session.
This ensures that neither side can decrypt the password without
the information stored on the other end of the request.
Both the server-side session variables and the client-side cookies
are deleted when the user logs out.
"""
key = Random.new().read(32)
iv = Random.new().read(16)
obj = AES.new(key, AES.MODE_CFB, iv)
message = request.POST.get("password")
if isinstance(message, str):
message = message.encode("utf-8")
ciphertext = obj.encrypt(message)
request.session["files_iv"] = base64.b64encode(iv).decode()
request.session["files_text"] = base64.b64encode(ciphertext).decode()
cookie_key = base64.b64encode(key).decode()
nexturl = request.GET.get("next", None)
if nexturl and nexturl.startswith("/files"):
response = redirect(nexturl)
else:
response = redirect("files")
response.set_cookie(key="files_key", value=cookie_key)
if "username" in request.POST:
request.session["filecenter_username"] = request.POST.get("username")
return response
else:
return render(request, "files/auth.html", {"is_admin": request.user.member_of("admin_all")})
[docs]@sensitive_variables("password")
def get_authinfo(request):
"""Get authentication info from the encrypted message."""
if ("files_iv" not in request.session) or ("files_text" not in request.session) or ("files_key" not in request.COOKIES):
return False
"""
Decrypt the password given the SERVER-side IV, SERVER-side
ciphertext, and CLIENT-side key.
See note above on why this is done.
"""
iv = base64.b64decode(request.session["files_iv"])
text = base64.b64decode(request.session["files_text"])
key = base64.b64decode(request.COOKIES["files_key"])
obj = AES.new(key, AES.MODE_CFB, iv)
password = obj.decrypt(text)
username = request.session["filecenter_username"] if "filecenter_username" in request.session else request.user.username
return {"username": username, "password": password}
@login_required
@deny_restricted
def files_type(request, fstype=None):
"""Do all processing (directory listing, file downloads) for a given filesystem."""
try:
host = Host.objects.get(code=fstype)
except Host.DoesNotExist:
messages.error(request, "Could not find host in database.")
return redirect("files")
if host.available_to_all:
pass
elif not host.visible_to(request.user):
messages.error(request, "You don't have permission to access this host.")
return redirect("files")
authinfo = get_authinfo(request)
if not authinfo:
return redirect("{}?next={}".format(reverse("files_auth"), request.get_full_path()))
try:
sftp = create_session(host.address, authinfo["username"], authinfo["password"])
except exceptions as e:
messages.error(request, e)
error_msg = str(e).lower()
if "authentication" in error_msg:
return redirect("files_auth")
return redirect("files")
finally:
# Delete the stored credentials, so they aren't mistakenly used or
# accessed later.
del authinfo
if host.directory:
host_dir = host.directory
if "{}" in host_dir:
host_dir = host_dir.format(request.user.username)
if "{win}" in host_dir:
host_dir = windows_dir_format(host_dir, request.user)
try:
sftp.chdir(host_dir)
except exceptions as e:
if "NoSuchFile" in str(e):
host_dir = "/"
try:
sftp.chdir(host_dir)
except exceptions as e2:
messages.error(request, e)
messages.error(request, f"Root directory: {e2}")
return redirect("files")
else:
messages.error(request, "Unable to access home folder -- showing root directory instead.")
else:
messages.error(request, e)
return redirect("files")
else:
try:
sftp.chdir(host_dir)
except exceptions as e:
messages.error(request, e)
return redirect("files")
default_dir = normpath(sftp.pwd)
def can_access_path(fsdir):
return normpath(fsdir).startswith(default_dir)
if "file" in request.GET:
# Download file
filepath = request.GET.get("file")
filepath = normpath(filepath)
filebase = os.path.basename(filepath)
filebase_escaped = re.sub(r"[^\w\s\-\.]", "", filebase).strip()
if can_access_path(filepath):
try:
fstat = sftp.stat(filepath)
except exceptions as e:
messages.error(request, f"Unable to access {filebase}: {e}")
return redirect(f"/files/{fstype}?dir={os.path.dirname(filepath)}")
if fstat.st_size > settings.FILES_MAX_DOWNLOAD_SIZE:
messages.error(request, "Too large to download (>200MB)")
return redirect(f"/files/{fstype}?dir={os.path.dirname(filepath)}")
with tempfile.TemporaryFile(prefix=f"ion_filecenter_{request.user.username}_{filebase_escaped}") as tmpfile:
try:
sftp.getfo(filepath, tmpfile)
except exceptions as e:
messages.error(request, e)
return redirect(f"/files/{fstype}?dir={os.path.dirname(filepath)}")
content_len = tmpfile.tell()
tmpfile.seek(0)
chunk_size = 8192
response = StreamingHttpResponse(FileWrapper(tmpfile, chunk_size), content_type="application/octet-stream")
response["Content-Length"] = content_len
response["Content-Disposition"] = f"attachment; filename={filebase_escaped}"
return response
fsdir = request.GET.get("dir")
if fsdir:
fsdir = normpath(fsdir)
if can_access_path(fsdir):
try:
sftp.chdir(fsdir)
except exceptions as e:
messages.error(request, e)
return redirect("files")
else:
messages.error(request, "Access to the path you provided is restricted.")
return redirect(f"/files/{fstype}/?dir={default_dir}")
if "zip" in request.GET:
dirbase_escaped = os.path.basename(fsdir)
dirbase_escaped = slugify(dirbase_escaped)
with tempfile.TemporaryDirectory(prefix=f"ion_filecenter_{request.user.username}_{dirbase_escaped}_zip") as tmpdir, tempfile.TemporaryFile(
prefix=f"ion_filecenter_{request.user.username}_{dirbase_escaped}"
) as tmpfile:
remote_directories = [fsdir]
totalsize = 0
while remote_directories:
rd = remote_directories.pop()
try:
remotelist = sftp.listdir(rd)
except PermissionError as e:
logger.debug("Exception %s on %s", e, rd)
continue
for item in remotelist:
itempath = os.path.join(rd, item)
try:
fstat = sftp.stat(itempath)
except exceptions as e:
logger.debug("Could not read %s: %s", item, e)
continue
if stat.S_ISDIR(fstat.st_mode):
remote_directories.append(itempath)
continue
totalsize += fstat.st_size
if totalsize > settings.FILES_MAX_DOWNLOAD_SIZE:
messages.error(request, "Too large to download (>200MB)")
return redirect(f"/files/{fstype}?dir={os.path.dirname(fsdir)}")
try:
localpath = os.path.join(tmpdir, os.path.relpath(rd, fsdir))
if not os.path.exists(localpath):
os.makedirs(localpath)
with open(os.path.join(localpath, item), "wb") as fh:
sftp.getfo(itempath, fh)
except exceptions as e:
logger.debug("Exception %s on %s", e, item)
continue
with zipfile.ZipFile(tmpfile, "w", zipfile.ZIP_DEFLATED) as zf:
for root, _, files in os.walk(tmpdir):
for f in files:
zf.write(os.path.join(root, f), os.path.join(os.path.relpath(root, tmpdir), f))
content_len = tmpfile.tell()
tmpfile.seek(0)
chunk_size = 8192
response = StreamingHttpResponse(FileWrapper(tmpfile, chunk_size), content_type="application/octet-stream")
response["Content-Length"] = content_len
if not dirbase_escaped:
dirbase_escaped = "files"
response["Content-Disposition"] = "attachment; filename={}".format(dirbase_escaped + ".zip")
return response
try:
listdir = sftp.listdir()
except exceptions as e:
messages.error(request, e)
listdir = []
files = []
for f in listdir:
if not f.startswith("."):
try:
fstat = sftp.stat(f)
except exceptions:
# If we can't stat the file, don't show it
continue
files.append(
{
"name": f,
"folder": sftp.isdir(f),
"stat": fstat,
"stat_mtime": datetime.datetime.fromtimestamp(int(fstat.st_mtime or 0)),
"too_big": fstat.st_size > settings.FILES_MAX_DOWNLOAD_SIZE,
}
)
current_dir = normpath(sftp.pwd) # current directory
dir_list = current_dir.split("/")
if len(dir_list) > 1 and not dir_list[-1]:
dir_list.pop()
parent_dir = "/".join(dir_list[:-1])
if not parent_dir:
parent_dir = "/"
files = sorted(files, key=lambda f: (not f["folder"], f["name"]))
context = {
"host": host,
"files": files,
"current_dir": current_dir,
"parent_dir": parent_dir if can_access_path(parent_dir) else None,
"max_download_mb": (settings.FILES_MAX_DOWNLOAD_SIZE / 1024 / 1024),
}
return render(request, "files/directory.html", context)
@login_required
@deny_restricted
def files_delete(request, fstype=None):
if "confirm" in request.POST:
filepath = request.POST.get("path", None)
else:
filepath = request.GET.get("dir", None)
if filepath is None:
return redirect("files")
try:
host = Host.objects.get(code=fstype)
except Host.DoesNotExist:
messages.error(request, "Could not find host in database.")
return redirect("files")
if host.available_to_all:
pass
elif not host.visible_to(request.user):
messages.error(request, "You don't have permission to access this host.")
return redirect("files")
authinfo = get_authinfo(request)
if not authinfo:
return redirect("{}?next={}".format(reverse("files_auth"), request.get_full_path()))
try:
sftp = create_session(host.address, authinfo["username"], authinfo["password"])
except exceptions as e:
messages.error(request, e)
error_msg = str(e).lower()
if "authentication" in error_msg:
return redirect("files_auth")
return redirect("files")
finally:
# Delete the stored credentials, so they aren't mistakenly used or
# accessed later.
del authinfo
if host.directory:
host_dir = host.directory
if "{}" in host_dir:
host_dir = host_dir.format(request.user.username)
if "{win}" in host_dir:
host_dir = windows_dir_format(host_dir, request.user)
try:
sftp.chdir(host_dir)
except exceptions as e:
messages.error(request, e)
return redirect("files")
default_dir = normpath(sftp.pwd)
def can_access_path(fsdir):
return normpath(fsdir).startswith(default_dir)
filepath = normpath(filepath)
if can_access_path(filepath):
try:
fstat = sftp.stat(filepath)
is_directory = stat.S_ISDIR(fstat.st_mode)
except exceptions as e:
messages.error(request, f"Unable to access {filepath}: {e}")
return redirect(f"/files/{fstype}?dir={os.path.dirname(filepath)}")
else:
messages.error(request, f"Unable to access {filepath}")
return redirect(f"/files/{fstype}?dir={os.path.dirname(filepath)}")
def rmtree(sftp, path):
for f in sftp.listdir_attr(path):
npath = os.path.join(path, f.filename)
if stat.S_ISDIR(f.st_mode):
rmtree(sftp, npath)
else:
sftp.remove(npath)
sftp.rmdir(path)
if "confirm" in request.POST:
try:
if is_directory:
rmtree(sftp, filepath)
else:
sftp.remove(filepath)
messages.success(request, ("Folder" if is_directory else "File") + " deleted!")
except PermissionError:
messages.error(request, "You are not allowed to delete this " + ("folder" if is_directory else "file") + "!")
except exceptions as e:
messages.error(request, str(e))
return redirect(f"/files/{fstype}?dir={os.path.dirname(filepath)}")
context = {"host": host, "remote_dir": os.path.dirname(filepath), "is_directory": is_directory}
return render(request, "files/delete.html", context)
@login_required
@deny_restricted
def files_upload(request, fstype=None):
fsdir = request.GET.get("dir", None)
if fsdir is None:
return redirect("files")
try:
host = Host.objects.get(code=fstype)
except Host.DoesNotExist:
messages.error(request, "Could not find host in database.")
return redirect("files")
if host.available_to_all:
pass
elif not host.visible_to(request.user):
messages.error(request, "You don't have permission to access this host.")
return redirect("files")
authinfo = get_authinfo(request)
if not authinfo:
return redirect("{}?next={}".format(reverse("files_auth"), request.get_full_path()))
if request.method == "POST":
form = UploadFileForm(request.POST, request.FILES)
if form.is_valid():
try:
sftp = create_session(host.address, authinfo["username"], authinfo["password"])
except exceptions as e:
messages.error(request, e)
return redirect("files")
else:
# Delete the stored credentials, so they aren't mistakenly used
# or accessed later.
del authinfo
if host.directory:
host_dir = host.directory
if "{}" in host_dir:
host_dir = host_dir.format(authinfo["username"])
if "{win}" in host_dir:
host_dir = windows_dir_format(host_dir, request.user)
try:
sftp.chdir(host_dir)
except exceptions as e:
messages.error(request, e)
return redirect("files")
default_dir = normpath(sftp.pwd)
def can_access_path(fsdir):
return normpath(fsdir).startswith(default_dir)
fsdir = normpath(fsdir)
if not can_access_path(fsdir):
messages.error(request, "Access to the path you provided is restricted.")
return redirect(f"/files/{fstype}?dir={default_dir}")
handle_file_upload(request.FILES["file"], fsdir, sftp, request)
return redirect(f"/files/{fstype}?dir={fsdir}")
else:
form = UploadFileForm()
context = {"host": host, "remote_dir": fsdir, "form": form, "max_upload_mb": (settings.FILES_MAX_UPLOAD_SIZE / 1024 / 1024)}
return render(request, "files/upload.html", context)
[docs]def handle_file_upload(file, fsdir, sftp, request=None):
try:
sftp.chdir(fsdir)
except exceptions as e:
messages.error(request, e)
return
remote_path = f"{fsdir}/{file.name}"
try:
sftp.putfo(file, remote_path)
except exceptions as e:
# Remote path does not exist
messages.error(request, f"Unable to upload: {e}")
return
messages.success(request, f"Uploaded {file.name} to {fsdir}")