pdrive 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pdrive/__main__.py +5 -0
- pdrive/backend/__init__.py +16 -0
- pdrive/backend/trash.py +296 -0
- pdrive/backend/web.py +295 -0
- pdrive/frontend/dist/assets/index-CT2iXuDN.css +1 -0
- pdrive/frontend/dist/assets/index-lAegCZoe.js +13 -0
- pdrive/frontend/dist/favicon.svg +30 -0
- pdrive/frontend/dist/index.html +38 -0
- pdrive/frontend/index.html +37 -0
- pdrive/frontend/public/favicon.svg +30 -0
- pdrive-0.9.0.dist-info/METADATA +78 -0
- pdrive-0.9.0.dist-info/RECORD +15 -0
- pdrive-0.9.0.dist-info/WHEEL +4 -0
- pdrive-0.9.0.dist-info/entry_points.txt +2 -0
- pdrive-0.9.0.dist-info/licenses/LICENSE +674 -0
pdrive/__main__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# pdrive - a web based document browser
|
|
3
|
+
# Copyright (C) 2017 Huy Nguyen
|
|
4
|
+
#
|
|
5
|
+
# This program is free software: you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the GNU General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
# (at your option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# This program is distributed in the hope that it will be useful,
|
|
11
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
# GNU General Public License for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU General Public License
|
|
16
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
pdrive/backend/trash.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# pdrive - a web based document browser
|
|
3
|
+
# Copyright (C) 2017 Huy Nguyen
|
|
4
|
+
#
|
|
5
|
+
# This program is free software: you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the GNU General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
# (at your option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# This program is distributed in the hope that it will be useful,
|
|
11
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
# GNU General Public License for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU General Public License
|
|
16
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
"""Move files and folders to the trash of the operating system.
|
|
19
|
+
|
|
20
|
+
Deleting from a file manager should be undoable, so nothing is unlinked here:
|
|
21
|
+
every item is handed to the trash the desktop itself uses. One backend per
|
|
22
|
+
platform, all of them standard library only:
|
|
23
|
+
|
|
24
|
+
* macOS - a rename into the trash of the volume the item lives on: ~/.Trash
|
|
25
|
+
for the boot volume, /Volumes/<volume>/.Trashes/<uid> for anything else.
|
|
26
|
+
* Windows - SHFileOperationW with FOF_ALLOWUNDO, the call Explorer itself
|
|
27
|
+
makes, so the item lands in the Recycle Bin of its own drive and keeps
|
|
28
|
+
working with "Restore".
|
|
29
|
+
* Linux and the BSDs - the freedesktop.org Trash specification that GNOME,
|
|
30
|
+
KDE and XFCE read: a move into $XDG_DATA_HOME/Trash (usually
|
|
31
|
+
~/.local/share/Trash) plus a .trashinfo record holding the original path.
|
|
32
|
+
Items on another filesystem go to the per user trash at the top of their
|
|
33
|
+
own mount, as the specification asks.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import datetime
|
|
37
|
+
import errno
|
|
38
|
+
import os
|
|
39
|
+
import shutil
|
|
40
|
+
import stat
|
|
41
|
+
import sys
|
|
42
|
+
import urllib.parse
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def move_to_trash(path):
|
|
46
|
+
"""Move `path` to the trash and return where it ended up.
|
|
47
|
+
|
|
48
|
+
The Windows Recycle Bin does not report the name it gave the item, so the
|
|
49
|
+
return value is None there. Raises OSError when the move fails, e.g. when
|
|
50
|
+
the file is gone or the trash folder is not writable.
|
|
51
|
+
"""
|
|
52
|
+
path = os.path.abspath(os.path.expanduser(path))
|
|
53
|
+
if not os.path.lexists(path):
|
|
54
|
+
raise FileNotFoundError(
|
|
55
|
+
errno.ENOENT, "no such file or directory", path)
|
|
56
|
+
|
|
57
|
+
if sys.platform == "darwin":
|
|
58
|
+
return _trash_macos(path)
|
|
59
|
+
if os.name == "nt":
|
|
60
|
+
return _trash_windows(path)
|
|
61
|
+
return _trash_freedesktop(path)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Shared helpers
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def _split_name(name):
|
|
69
|
+
"""Split a file name into a stem and an extension, dot files included.
|
|
70
|
+
|
|
71
|
+
"notes" -> ("notes", ""), "notes.txt" -> ("notes", ".txt"),
|
|
72
|
+
".bashrc" -> (".bashrc", "")
|
|
73
|
+
"""
|
|
74
|
+
stem, dot, extension = name.rpartition(".")
|
|
75
|
+
if not stem:
|
|
76
|
+
return name, ""
|
|
77
|
+
return stem, dot + extension
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _free_name(directory, name):
|
|
81
|
+
"""A variant of `name` that is not taken in `directory` yet.
|
|
82
|
+
|
|
83
|
+
Finder's habit is copied here: "report.pdf", "report 2.pdf", ...
|
|
84
|
+
"""
|
|
85
|
+
stem, extension = _split_name(name)
|
|
86
|
+
candidate = name
|
|
87
|
+
counter = 1
|
|
88
|
+
while os.path.lexists(os.path.join(directory, candidate)):
|
|
89
|
+
counter += 1
|
|
90
|
+
candidate = "%s %d%s" % (stem, counter, extension)
|
|
91
|
+
return candidate
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _free_trash_name(files_dir, info_dir, name):
|
|
95
|
+
"""A name free in both halves of a freedesktop trash, since the item and
|
|
96
|
+
its .trashinfo record have to share it."""
|
|
97
|
+
stem, extension = _split_name(name)
|
|
98
|
+
candidate = name
|
|
99
|
+
counter = 1
|
|
100
|
+
while (os.path.lexists(os.path.join(files_dir, candidate)) or
|
|
101
|
+
os.path.lexists(os.path.join(info_dir, candidate + ".trashinfo"))):
|
|
102
|
+
counter += 1
|
|
103
|
+
candidate = "%s %d%s" % (stem, counter, extension)
|
|
104
|
+
return candidate
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _move_into(directory, path):
|
|
108
|
+
"""Move `path` into `directory`, giving it a free name, and return the new
|
|
109
|
+
path. rename() keeps it atomic; a copy is only made when the trash sits on
|
|
110
|
+
another filesystem."""
|
|
111
|
+
name = _free_name(directory, os.path.basename(path.rstrip("/\\")))
|
|
112
|
+
destination = os.path.join(directory, name)
|
|
113
|
+
try:
|
|
114
|
+
os.rename(path, destination)
|
|
115
|
+
except OSError as error:
|
|
116
|
+
if error.errno != errno.EXDEV:
|
|
117
|
+
raise
|
|
118
|
+
shutil.move(path, destination)
|
|
119
|
+
return destination
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _mount_point(path):
|
|
123
|
+
"""The top of the filesystem `path` lives on."""
|
|
124
|
+
path = os.path.dirname(os.path.abspath(path))
|
|
125
|
+
while not os.path.ismount(path):
|
|
126
|
+
parent = os.path.dirname(path)
|
|
127
|
+
if parent == path:
|
|
128
|
+
break
|
|
129
|
+
path = parent
|
|
130
|
+
return path
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _on_same_device(path, other):
|
|
134
|
+
"""True when `path` sits on the same filesystem as `other`, so that it can
|
|
135
|
+
be renamed instead of copied."""
|
|
136
|
+
try:
|
|
137
|
+
return os.stat(path).st_dev == os.stat(other).st_dev
|
|
138
|
+
except OSError:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _usable_directory(directory):
|
|
143
|
+
"""Create `directory` if needed and report whether it can be written to."""
|
|
144
|
+
try:
|
|
145
|
+
os.makedirs(directory, exist_ok=True)
|
|
146
|
+
return os.access(directory, os.W_OK | os.X_OK)
|
|
147
|
+
except OSError:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# macOS
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def _trash_macos(path):
|
|
156
|
+
home = os.path.expanduser("~")
|
|
157
|
+
home_trash = os.path.join(home, ".Trash")
|
|
158
|
+
|
|
159
|
+
if _on_same_device(path, home):
|
|
160
|
+
os.makedirs(home_trash, exist_ok=True)
|
|
161
|
+
return _move_into(home_trash, path)
|
|
162
|
+
|
|
163
|
+
# another volume: macOS keeps its items in the volume's own trash so that
|
|
164
|
+
# emptying the Trash does not have to copy gigabytes across drives
|
|
165
|
+
volume_trash = os.path.join(
|
|
166
|
+
_mount_point(path), ".Trashes", str(os.getuid()))
|
|
167
|
+
if _usable_directory(volume_trash):
|
|
168
|
+
return _move_into(volume_trash, path)
|
|
169
|
+
|
|
170
|
+
# a volume the user cannot write a .Trashes folder into: the home trash is
|
|
171
|
+
# a copy across filesystems, which still beats destroying the item
|
|
172
|
+
os.makedirs(home_trash, exist_ok=True)
|
|
173
|
+
return _move_into(home_trash, path)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# Windows
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _trash_windows(path):
|
|
181
|
+
import ctypes
|
|
182
|
+
from ctypes import wintypes
|
|
183
|
+
|
|
184
|
+
class SHFILEOPSTRUCTW(ctypes.Structure):
|
|
185
|
+
_fields_ = [
|
|
186
|
+
("hwnd", wintypes.HWND),
|
|
187
|
+
("wFunc", wintypes.UINT),
|
|
188
|
+
("pFrom", wintypes.LPCWSTR),
|
|
189
|
+
("pTo", wintypes.LPCWSTR),
|
|
190
|
+
("fFlags", ctypes.c_uint16),
|
|
191
|
+
("fAnyOperationsAborted", wintypes.BOOL),
|
|
192
|
+
("hNameMappings", ctypes.c_void_p),
|
|
193
|
+
("lpszProgressTitle", wintypes.LPCWSTR),
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
FO_DELETE = 3
|
|
197
|
+
FOF_SILENT = 0x0004
|
|
198
|
+
FOF_NOCONFIRMATION = 0x0010
|
|
199
|
+
FOF_ALLOWUNDO = 0x0040 # the flag that turns a delete into a recycle
|
|
200
|
+
FOF_NOERRORUI = 0x0400
|
|
201
|
+
FOF_NOCONFIRMMKDIR = 0x0200
|
|
202
|
+
|
|
203
|
+
# SHFileOperationW only understands the old MAX_PATH (260) interface, so a
|
|
204
|
+
# long path is handed over through its 8.3 short name when it has one
|
|
205
|
+
if len(path) >= 260:
|
|
206
|
+
buffer = ctypes.create_unicode_buffer(260)
|
|
207
|
+
if ctypes.windll.kernel32.GetShortPathNameW(path, buffer, 260):
|
|
208
|
+
path = buffer.value
|
|
209
|
+
|
|
210
|
+
operation = SHFILEOPSTRUCTW()
|
|
211
|
+
operation.wFunc = FO_DELETE
|
|
212
|
+
# the source list is NUL terminated, and ctypes adds the second NUL
|
|
213
|
+
operation.pFrom = path + "\0"
|
|
214
|
+
operation.fFlags = (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT |
|
|
215
|
+
FOF_NOERRORUI | FOF_NOCONFIRMMKDIR)
|
|
216
|
+
|
|
217
|
+
result = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(operation))
|
|
218
|
+
if result != 0 or operation.fAnyOperationsAborted:
|
|
219
|
+
raise OSError(
|
|
220
|
+
errno.EIO,
|
|
221
|
+
"the Recycle Bin refused the item (SHFileOperationW code %d)"
|
|
222
|
+
% result, path)
|
|
223
|
+
# the Recycle Bin does not tell us the name the item was given
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# Linux, BSD - freedesktop.org Trash specification
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def _freedesktop_trash_dir(path):
|
|
232
|
+
data_home = os.environ.get("XDG_DATA_HOME") or os.path.join(
|
|
233
|
+
os.path.expanduser("~"), ".local", "share")
|
|
234
|
+
home_trash = os.path.join(data_home, "Trash")
|
|
235
|
+
|
|
236
|
+
if _on_same_device(path, data_home):
|
|
237
|
+
return home_trash
|
|
238
|
+
|
|
239
|
+
# The specification prefers a trash on the item's own mount, and only then
|
|
240
|
+
# a copy into the home trash. ".Trash" is shared between users when it is
|
|
241
|
+
# sticky and owned by root, in which case each user gets a subfolder.
|
|
242
|
+
mount = _mount_point(path)
|
|
243
|
+
shared = os.path.join(mount, ".Trash")
|
|
244
|
+
try:
|
|
245
|
+
info = os.stat(shared)
|
|
246
|
+
shared_ok = (stat.S_ISDIR(info.st_mode) and
|
|
247
|
+
info.st_uid == 0 and
|
|
248
|
+
info.st_mode & stat.S_ISVTX)
|
|
249
|
+
except OSError:
|
|
250
|
+
shared_ok = False
|
|
251
|
+
|
|
252
|
+
if shared_ok:
|
|
253
|
+
candidate = os.path.join(shared, str(os.getuid()))
|
|
254
|
+
else:
|
|
255
|
+
candidate = os.path.join(mount, ".Trash-%d" % os.getuid())
|
|
256
|
+
|
|
257
|
+
if _usable_directory(candidate):
|
|
258
|
+
return candidate
|
|
259
|
+
return home_trash
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _trash_freedesktop(path):
|
|
263
|
+
trash = _freedesktop_trash_dir(path)
|
|
264
|
+
files_dir = os.path.join(trash, "files")
|
|
265
|
+
info_dir = os.path.join(trash, "info")
|
|
266
|
+
os.makedirs(files_dir, exist_ok=True)
|
|
267
|
+
os.makedirs(info_dir, exist_ok=True)
|
|
268
|
+
|
|
269
|
+
# written before the move, as the specification requires, so that a crash
|
|
270
|
+
# halfway through leaves a record behind instead of an orphan
|
|
271
|
+
name = _free_trash_name(files_dir, info_dir,
|
|
272
|
+
os.path.basename(path.rstrip("/\\")))
|
|
273
|
+
record = "[Trash Info]\nPath=%s\nDeletionDate=%s\n" % (
|
|
274
|
+
urllib.parse.quote(path, safe="/"),
|
|
275
|
+
datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
|
|
276
|
+
)
|
|
277
|
+
info_path = os.path.join(info_dir, name + ".trashinfo")
|
|
278
|
+
with open(info_path, "w", encoding="utf-8") as handle:
|
|
279
|
+
handle.write(record)
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
destination = os.path.join(files_dir, name)
|
|
283
|
+
try:
|
|
284
|
+
os.rename(path, destination)
|
|
285
|
+
except OSError as error:
|
|
286
|
+
if error.errno != errno.EXDEV:
|
|
287
|
+
raise
|
|
288
|
+
shutil.move(path, destination)
|
|
289
|
+
except OSError:
|
|
290
|
+
# do not leave a .trashinfo pointing at nothing
|
|
291
|
+
try:
|
|
292
|
+
os.unlink(info_path)
|
|
293
|
+
except OSError:
|
|
294
|
+
pass
|
|
295
|
+
raise
|
|
296
|
+
return destination
|
pdrive/backend/web.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# pdrive - a web based document browser
|
|
3
|
+
# Copyright (C) 2017 Huy Nguyen
|
|
4
|
+
#
|
|
5
|
+
# This program is free software: you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the GNU General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
# (at your option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# This program is distributed in the hope that it will be useful,
|
|
11
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
# GNU General Public License for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU General Public License
|
|
16
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from flask import cli
|
|
20
|
+
import sys
|
|
21
|
+
import platform
|
|
22
|
+
import os.path as pth
|
|
23
|
+
import os
|
|
24
|
+
import json
|
|
25
|
+
import shutil
|
|
26
|
+
|
|
27
|
+
from argparse import ArgumentParser
|
|
28
|
+
from flask import Flask
|
|
29
|
+
from flask import make_response
|
|
30
|
+
from flask import request
|
|
31
|
+
from flask import render_template
|
|
32
|
+
from flask import send_file
|
|
33
|
+
from flask import send_from_directory
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
from .trash import move_to_trash
|
|
37
|
+
except ImportError: # running this file as a plain script, not a package
|
|
38
|
+
from trash import move_to_trash
|
|
39
|
+
|
|
40
|
+
frontend_path = pth.join(
|
|
41
|
+
pth.split(pth.split(pth.abspath(__file__))[0])[0], "frontend")
|
|
42
|
+
|
|
43
|
+
print(frontend_path)
|
|
44
|
+
|
|
45
|
+
app = Flask(__name__,
|
|
46
|
+
static_folder=pth.join(frontend_path, "dist"),
|
|
47
|
+
static_url_path="")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
basedir = os.path.abspath(os.curdir)
|
|
51
|
+
hostname = platform.node()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.route("/api", methods=["GET", "POST"])
|
|
55
|
+
def api():
|
|
56
|
+
global basedir
|
|
57
|
+
payload = request.form.get("payload")
|
|
58
|
+
if payload is None:
|
|
59
|
+
nodes = [process(pth.join(basedir, n)) for n in os.listdir(basedir)]
|
|
60
|
+
data = {
|
|
61
|
+
"basedir": basedir,
|
|
62
|
+
"cwd": basedir,
|
|
63
|
+
"hostname": hostname,
|
|
64
|
+
"nodes": nodes
|
|
65
|
+
}
|
|
66
|
+
return json.dumps(data)
|
|
67
|
+
else:
|
|
68
|
+
payload = json.loads(payload)
|
|
69
|
+
print(payload)
|
|
70
|
+
if payload["command"] == "trash":
|
|
71
|
+
# Deleting is a move to the trash of the host system (the Recycle
|
|
72
|
+
# Bin, ~/.Trash, the freedesktop trash, ...), so an accidental
|
|
73
|
+
# click is always undoable. Every path is reported back: some may
|
|
74
|
+
# have made it while others did not, and the UI says so.
|
|
75
|
+
trashed = []
|
|
76
|
+
failed = []
|
|
77
|
+
for path in payload["paths"]:
|
|
78
|
+
try:
|
|
79
|
+
trashed.append(move_to_trash(path) or path)
|
|
80
|
+
except OSError as error:
|
|
81
|
+
failed.append({"path": path, "error": str(error)})
|
|
82
|
+
data = dict(payload)
|
|
83
|
+
data["trashed"] = trashed
|
|
84
|
+
data["failed"] = failed
|
|
85
|
+
return json.dumps(data)
|
|
86
|
+
elif payload["command"] == "rm":
|
|
87
|
+
# Permanent deletion. The UI only sends this after it has asked the
|
|
88
|
+
# user to confirm, because there is nothing to recover from here.
|
|
89
|
+
deleted = []
|
|
90
|
+
failed = []
|
|
91
|
+
for path in payload["paths"]:
|
|
92
|
+
try:
|
|
93
|
+
if os.path.isdir(path) and not os.path.islink(path):
|
|
94
|
+
shutil.rmtree(path)
|
|
95
|
+
else:
|
|
96
|
+
os.unlink(path)
|
|
97
|
+
deleted.append(path)
|
|
98
|
+
except OSError as error:
|
|
99
|
+
failed.append({"path": path, "error": str(error)})
|
|
100
|
+
data = dict(payload)
|
|
101
|
+
data["deleted"] = deleted
|
|
102
|
+
data["failed"] = failed
|
|
103
|
+
return json.dumps(data)
|
|
104
|
+
elif payload["command"] == "ls":
|
|
105
|
+
path = payload["path"]
|
|
106
|
+
nodes = [process(pth.join(path, n)) for n in os.listdir(path)]
|
|
107
|
+
data = {
|
|
108
|
+
"basedir": basedir,
|
|
109
|
+
"cwd": path,
|
|
110
|
+
"hostname": hostname,
|
|
111
|
+
"nodes": nodes
|
|
112
|
+
}
|
|
113
|
+
return json.dumps(data)
|
|
114
|
+
elif payload["command"] == "mv":
|
|
115
|
+
paths = payload["paths"]
|
|
116
|
+
destination = payload["destination"]
|
|
117
|
+
for p in paths:
|
|
118
|
+
shutil.move(p, destination)
|
|
119
|
+
return json.dumps(payload)
|
|
120
|
+
elif payload["command"] == "cp":
|
|
121
|
+
paths = payload["paths"]
|
|
122
|
+
destination = payload["destination"]
|
|
123
|
+
for p in paths:
|
|
124
|
+
name = os.path.split(p)[1]
|
|
125
|
+
copy = shutil.copytree if os.path.isdir(p) else shutil.copy
|
|
126
|
+
dest = pth.join(destination, name) if os.path.isdir(
|
|
127
|
+
destination) and os.path.isdir(p) else destination
|
|
128
|
+
copy(p, dest)
|
|
129
|
+
return json.dumps(payload)
|
|
130
|
+
|
|
131
|
+
elif payload["command"] == "mkdir":
|
|
132
|
+
path = payload["path"]
|
|
133
|
+
if not os.path.exists(path):
|
|
134
|
+
os.makedirs(path)
|
|
135
|
+
return json.dumps(process(path))
|
|
136
|
+
|
|
137
|
+
return json.dumps(payload)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.route("/serve")
|
|
141
|
+
def serve_file():
|
|
142
|
+
fpath = request.args.get("file")
|
|
143
|
+
if not fpath or not os.path.isfile(fpath):
|
|
144
|
+
return make_response("file not found", 404)
|
|
145
|
+
response = send_file(fpath)
|
|
146
|
+
return response
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.route("/download")
|
|
150
|
+
def download_file():
|
|
151
|
+
fpath = request.args.get("file")
|
|
152
|
+
if not fpath or not os.path.isfile(fpath):
|
|
153
|
+
return make_response("file not found", 404)
|
|
154
|
+
name = os.path.split(fpath)[1]
|
|
155
|
+
response = send_file(fpath)
|
|
156
|
+
response.headers['Content-Disposition'] = "attachment; filename=%s" % name
|
|
157
|
+
return response
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.route("/upload", methods=["POST"])
|
|
161
|
+
def upload_file():
|
|
162
|
+
fd = request.files['file']
|
|
163
|
+
basedir = request.form["basedir"]
|
|
164
|
+
fpath = os.path.join(basedir, fd.filename)
|
|
165
|
+
fd.save(fpath)
|
|
166
|
+
node = process(fpath)
|
|
167
|
+
return json.dumps(node)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.route("/")
|
|
171
|
+
def index_page():
|
|
172
|
+
return send_file(pth.join(frontend_path, "dist", "index.html"))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def process(path):
|
|
176
|
+
import os
|
|
177
|
+
import stat
|
|
178
|
+
import datetime
|
|
179
|
+
import mimetypes
|
|
180
|
+
|
|
181
|
+
def timestamp(seconds):
|
|
182
|
+
# local time, and a format that also sorts correctly as a string
|
|
183
|
+
return datetime.datetime.fromtimestamp(seconds).strftime("%Y-%m-%d %H:%M:%S")
|
|
184
|
+
|
|
185
|
+
name = pth.split(path)[1]
|
|
186
|
+
statinfo = os.stat(path)
|
|
187
|
+
|
|
188
|
+
if stat.S_ISDIR(statinfo.st_mode):
|
|
189
|
+
node_type = "dir"
|
|
190
|
+
else:
|
|
191
|
+
node_type = "file"
|
|
192
|
+
|
|
193
|
+
mimetype = None
|
|
194
|
+
if node_type == "file":
|
|
195
|
+
mimetype, _ = mimetypes.guess_type(path)
|
|
196
|
+
|
|
197
|
+
# st_birthtime is the real creation time (macOS, Windows, some BSDs).
|
|
198
|
+
# Platforms without it (Linux) fall back to st_ctime, the last inode
|
|
199
|
+
# change, which is the closest thing they have.
|
|
200
|
+
created = getattr(statinfo, "st_birthtime", None) or statinfo.st_ctime
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
"name": name,
|
|
204
|
+
"path": path,
|
|
205
|
+
"type": node_type,
|
|
206
|
+
"mimetype": mimetype,
|
|
207
|
+
"size": statinfo.st_size,
|
|
208
|
+
"ctime": timestamp(created),
|
|
209
|
+
"mtime": timestamp(statinfo.st_mtime)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def print_banner(args):
|
|
214
|
+
hostname = platform.node()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
print("")
|
|
219
|
+
print("\x1b[32m================\x1b[0m")
|
|
220
|
+
print("\x1b[32m- PDrive -\x1b[0m")
|
|
221
|
+
print("\x1b[32m================\x1b[0m")
|
|
222
|
+
print("")
|
|
223
|
+
print(f"\x1b[32mStarting server at http://{args.host}:{args.port}\x1b[0m")
|
|
224
|
+
print("")
|
|
225
|
+
print("\x1b[32mYou can SSH Tunnel to this service in a "
|
|
226
|
+
"separate terminal with:\x1b[0m")
|
|
227
|
+
print("")
|
|
228
|
+
print(f"\x1b[32m ssh -N -L {args.port}:localhost:{args.port} "
|
|
229
|
+
f"{hostname}\x1b[0m")
|
|
230
|
+
print("")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def main():
|
|
234
|
+
p = ArgumentParser()
|
|
235
|
+
p.add_argument("-H", "--host", default="127.0.0.1",
|
|
236
|
+
help="Host address to bind server to (default: 127.0.0.1)")
|
|
237
|
+
p.add_argument("-p", "--port", default=9999,
|
|
238
|
+
help="Host port to bind server to (default: 9999)")
|
|
239
|
+
a = p.parse_args()
|
|
240
|
+
|
|
241
|
+
# If gunicorn is available use it rather than the
|
|
242
|
+
# default dev server
|
|
243
|
+
import importlib
|
|
244
|
+
import importlib.util
|
|
245
|
+
gunicorn_import_available = importlib.util.find_spec("gunicorn")
|
|
246
|
+
if gunicorn_import_available:
|
|
247
|
+
import gunicorn.app.base
|
|
248
|
+
import multiprocessing
|
|
249
|
+
|
|
250
|
+
class Application(gunicorn.app.base.BaseApplication):
|
|
251
|
+
|
|
252
|
+
def __init__(self, app, options=None):
|
|
253
|
+
self.options = options or {}
|
|
254
|
+
self.application = app
|
|
255
|
+
super().__init__()
|
|
256
|
+
|
|
257
|
+
def load_config(self):
|
|
258
|
+
config = {key: value for key, value in self.options.items()
|
|
259
|
+
if key in self.cfg.settings and value is not None}
|
|
260
|
+
for key, value in config.items():
|
|
261
|
+
self.cfg.set(key.lower(), value)
|
|
262
|
+
|
|
263
|
+
def load(self):
|
|
264
|
+
return self.application
|
|
265
|
+
|
|
266
|
+
# start app
|
|
267
|
+
print_banner(a)
|
|
268
|
+
|
|
269
|
+
cpu_count = multiprocessing.cpu_count()
|
|
270
|
+
options = {
|
|
271
|
+
'bind': '%s:%s' % (a.host, a.port),
|
|
272
|
+
'workers': min(cpu_count+1, 4),
|
|
273
|
+
'timeout': 120,
|
|
274
|
+
}
|
|
275
|
+
Application(app, options).run()
|
|
276
|
+
else:
|
|
277
|
+
print_banner(a)
|
|
278
|
+
|
|
279
|
+
print("")
|
|
280
|
+
print("\x1b[33mYou are running PDrive using a built-in minimal web server. For \n"
|
|
281
|
+
"better file upload & download performance, we recommend you install \n"
|
|
282
|
+
"the gunicorn web server python package. PDrive will automatically \n"
|
|
283
|
+
"use it if it is available:\x1b[0m")
|
|
284
|
+
print("")
|
|
285
|
+
print(" pip install gunicorn")
|
|
286
|
+
print("")
|
|
287
|
+
|
|
288
|
+
log = logging.getLogger('werkzeug')
|
|
289
|
+
log.disabled = True
|
|
290
|
+
cli.show_server_banner = lambda *_: None
|
|
291
|
+
app.run(host=a.host, port=a.port)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == '__main__':
|
|
295
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*{margin:0;padding:0}body{background:#fff;font-family:-apple-system,BlinkMacSystemFont,SF Pro Text,Segoe UI,system-ui,Roboto,Helvetica Neue,Arial,sans-serif}button{font-family:inherit}.app{height:100%}.noselect{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.logo-mark{flex-shrink:0;font-size:20px;color:#fff}.header-bar-brand{align-items:baseline;display:flex;gap:10px;min-width:0}.header-bar-brand .logo-icon{align-self:center;border-radius:5px;box-shadow:0 1px 3px #00000047;display:block;flex-shrink:0;height:22px;width:22px}.header-bar-host{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);color:#ffffffb3;font-size:13px;max-width:clamp(0px,calc(100% - 684px),40vw);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.header-bar{background:#20a0ff;padding:4px 10px;box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;position:relative}.header-bar-actions{display:flex;align-items:center;gap:6px}.header-bar-button-group{display:flex;gap:6px}.header-bar-button{background-color:#285688f1;border:1px solid #28568821;color:#9ab;border-radius:4px;cursor:pointer;padding:5px 10px;line-height:1}.header-bar-button:hover{background-color:#285688aa}.header-bar-button.active{background-color:#28568861;color:#fff}.header-bar-button .material-icons{font-size:20px;line-height:1;vertical-align:middle}.btn{background:#fff;border:1px solid #DCDFE6;border-radius:4px;color:#606266;cursor:pointer;font-family:inherit;font-size:14px;padding:8px 16px;transition:background-color .15s,border-color .15s,color .15s}.btn:hover{background:#ecf5ff;border-color:#c6e2ff;color:#409eff}.btn:focus-visible{outline:2px solid #409EFF;outline-offset:1px}.btn-primary{background:#409eff;border-color:#409eff;color:#fff}.btn-primary:hover{background:#66b1ff;border-color:#66b1ff;color:#fff}.btn-danger{background:#e03131;border-color:#e03131;color:#fff}.btn-danger:hover{background:#f03e3e;border-color:#f03e3e;color:#fff}.btn-danger:focus-visible{outline:2px solid #E03131;outline-offset:1px}.text-input{border:1px solid #DCDFE6;border-radius:4px;box-sizing:border-box;color:#606266;font-family:inherit;font-size:14px;padding:8px 11px;width:100%}.text-input:focus{border-color:#409eff;outline:none}.modal-backdrop{align-items:center;background:#00000080;display:flex;justify-content:center;inset:0;position:fixed;z-index:1000}.modal{background:#fff;border-radius:6px;box-shadow:0 8px 24px #00000040;max-width:calc(100vw - 32px);width:420px}.modal-header{padding:20px 20px 0}.modal-title{font-size:18px;font-weight:600;margin:0}.modal-body{padding:20px}.confirm-message{color:#606266;line-height:1.5;margin:0}.modal-footer{display:flex;gap:8px;justify-content:flex-end;padding:0 20px 20px}.dropdown{display:inline-block;position:relative}.dropdown-menu{background:#fff;border-radius:4px;box-shadow:0 2px 12px #00000026;list-style:none;min-width:170px;padding:6px 0;position:absolute;right:0;top:calc(100% + 4px);z-index:1000}.dropdown-menu button{align-items:center;background:none;border:0;color:#606266;cursor:pointer;display:flex;font-family:inherit;font-size:14px;padding:9px 16px;text-align:left;width:100%}.dropdown-menu button:hover,.dropdown-menu button:focus-visible{background:#ecf5ff;color:#409eff;outline:none}.progress{align-items:center;display:flex;gap:8px;margin-top:8px}.progress-bar{background:#ebeef5;border-radius:3px;flex:1;height:6px;overflow:hidden}.progress-inner{background:#409eff;height:100%;transition:width .2s}.progress-text{color:#606266;font-size:12px;min-width:34px;text-align:right}.upload-overlay{position:fixed;background-color:#000000e6;z-index:999;padding:20px;inset:0}.upload-overlay-message{position:relative;margin:100px auto;width:400px;padding:20px;color:#fff;text-align:center;font-size:24px}.job-progress{position:fixed;bottom:20px;right:20px;width:300px;background-color:#fff;box-shadow:0 2px 6px #999;z-index:999}.job-progress-header{background:#8492a6;padding:10px 20px;color:#fff}.job-progress-task{padding:20px;font-size:12px}.toolbar-view{background:#324057;color:#fff;display:flex;align-items:center;justify-content:space-between}.breadcrumb-view{padding:8px 10px;border-right:1px solid #1F2D3D;flex:1;min-width:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;white-space:nowrap}.breadcrumb-view::-webkit-scrollbar{display:none}.breadcrumb-view .segment{border-radius:3px;cursor:pointer;padding:2px 4px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.breadcrumb-view .segment:hover{background:#ffffff1f}.breadcrumb-view .material-icons{vertical-align:middle}.new-button-view{margin-right:10px}.new-button{margin:3px 0 3px 16px;padding:5px 8px 5px 10px}.new-button .material-icons{vertical-align:middle}.new-menu-icon{vertical-align:text-bottom;margin-right:6px}.contextual-menu{position:absolute;top:0;left:0;width:200px;background:#fff;z-index:999;font-size:.9em;box-shadow:0 0 10px #0000004d;border-radius:6px;overflow:hidden}.contextual-menu div{padding:10px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.contextual-menu div:hover{background:#e5e9f2;cursor:pointer}.contextual-menu div:active{background:#1d8ce0;color:#fff}.contextual-menu .contextual-menu-separator{border-top:2px solid #E5E9F2}.contextual-menu .contextual-menu-danger{color:#c92a2a}.contextual-menu .contextual-menu-danger:hover{background:#fff0f0;color:#c92a2a}.grid-view{display:grid;grid-template-columns:repeat(auto-fill,100px);gap:44px;padding:30px;justify-content:start;align-content:start;overflow:hidden}.grid-view .grid-item{display:flex;flex-direction:column;width:100px}.grid-item-preview{text-align:center;height:80px;width:80px;margin:0 auto 4px;vertical-align:middle;transition:background-color .05s ease-in-out}.grid-view .grid-item.selected .grid-item-preview{background:#eaecef;border-radius:6px}.grid-view .grid-item-preview img{box-sizing:border-box;display:block;margin:8px auto 0;border-radius:4px;box-shadow:0 1px 3px #0000002e}.grid-view .grid-item-preview .tile-icon{display:flex;justify-content:center;padding-top:8px}.grid-view .grid-item .filename{border-radius:4px;padding:4px;text-align:center;font-size:.8em;line-height:1.25;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;height:2.5em;overflow:hidden;overflow-wrap:anywhere;transition:background-color .05s ease-in-out}.grid-view .grid-item:hover .filename{background:#f1f3f6}.grid-view .grid-item.selected .filename{background:#0064e1;color:#fff;overflow-x:hidden}table{width:100%;display:table;border-collapse:collapse;border-spacing:0;box-sizing:content-box;font-size:13px}table thead{background:#eff2f7}table tr.selected{background:#0064e1;color:#fff}table tbody tr{transition:background-color .05s ease-in-out}table tbody tr:hover:not(.selected){background:#f1f3f6}table tbody tr:nth-child(2n):not(.selected){background:#f7f8fa}table td{height:34px;padding-left:18px;padding-right:18px;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}table th{border-bottom:1px solid #e0e6ed;white-space:nowrap;overflow:hidden;background-color:#eff2f7;text-align:left;height:34px;padding-left:18px;padding-right:18px;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}table td .row-caret{display:inline-block;height:18px;line-height:18px;margin:0 2px 0 -2px;vertical-align:middle;width:18px}table td button.row-caret{background:none;border:0;border-radius:3px;color:inherit;cursor:pointer;padding:0;text-align:center}table td button.row-caret:hover{background:#0000001a}table td .row-caret .chevron{display:block;font-size:18px;line-height:18px;transition:transform .12s ease-in-out}table td .row-caret.expanded .chevron{transform:rotate(90deg)}table tbody tr.loading-row td{color:#909399;font-style:italic}.file-icon{display:inline-block;line-height:0;vertical-align:middle;filter:drop-shadow(0 1px 1.5px rgba(0,0,0,.22))}tr.selected .file-icon{filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))}.file-icon svg{display:block}.material-icons{vertical-align:top}.material-icons.md-18{font-size:18px}.material-icons.md-24{font-size:24px}.material-icons.md-36{font-size:36px}.material-icons.md-48{font-size:48px}.material-icons.md-dark{color:#0000004d}.material-icons.md-dark.md-inactive{color:#00000042}.material-icons.md-light{color:#fff}.material-icons.md-light.md-inactive{color:#ffffff4d}@keyframes preview-fade-in{0%{opacity:0}}@keyframes preview-fade-out{to{opacity:0}}@keyframes preview-pop-in{0%{opacity:0;transform:scale(.985)}}@keyframes preview-pop-out{to{opacity:0;transform:scale(.99)}}.preview-backdrop{animation:preview-fade-in .15s ease-out;background:#000000d1}.modal-preview{animation:preview-pop-in .18s cubic-bezier(.2,.8,.3,1);background:transparent;border-radius:0;box-shadow:none;display:flex;flex-direction:column;gap:16px;max-height:calc(100vh - 40px);max-width:calc(100vw - 40px);width:auto}.preview-backdrop.is-closing{animation:preview-fade-out .14s ease-in forwards;pointer-events:none}.preview-backdrop.is-closing .modal-preview{animation:preview-pop-out .14s ease-in forwards}@media(prefers-reduced-motion:reduce){.preview-backdrop,.modal-preview,.preview-backdrop.is-closing,.preview-backdrop.is-closing .modal-preview{animation:none}.preview-backdrop.is-closing{opacity:0}}.preview-content{align-items:center;display:flex;flex:1 1 auto;justify-content:center;min-height:0;min-width:0}.preview-media{border-radius:4px;display:block;max-height:calc(100vh - 120px);max-width:min(1200px,calc(100vw - 40px))}audio.preview-media{max-width:calc(100vw - 40px);width:420px}.preview-frame{background:#fff;border:0;border-radius:4px;height:calc(100vh - 120px);width:min(1000px,calc(100vw - 40px))}.preview-text-panel{background:#fff;border-radius:6px;box-sizing:border-box;max-height:calc(100vh - 120px);overflow:auto;padding:16px 18px;width:min(900px,calc(100vw - 40px))}.preview-text{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.6;margin:0;tab-size:4;white-space:pre}.preview-note{color:#8a6d3b;font-size:12px;margin:0 0 12px}.preview-placeholder{color:#8492a6;padding:48px 0;text-align:center}.preview-placeholder .file-icon{display:block;margin:0 auto 10px}.preview-content>.preview-placeholder{color:#ffffff8c;padding:64px 0}.preview-bar{align-items:center;color:#ffffffd9;display:flex;flex-shrink:0;font-size:13px;gap:6px;justify-content:center;min-height:32px}.preview-caption{align-items:baseline;display:flex;gap:8px;justify-content:center;max-width:min(60vw,520px);min-width:0;overflow:hidden}.preview-name{color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.preview-detail{color:#ffffff73;flex-shrink:0;font-size:12px}.preview-bar-divider{background:#fff3;height:16px;margin:0 4px;width:1px}.preview-action{align-items:center;background:none;border:0;border-radius:50%;color:#ffffffbf;cursor:pointer;display:flex;height:32px;justify-content:center;padding:0;width:32px}.preview-action:hover:not(:disabled){background:#ffffff1f;color:#fff}.preview-action:disabled{color:#ffffff38;cursor:default}.selection-surface{min-height:1px}body.drag-selecting{-webkit-user-select:none;user-select:none}.selection-rect{background:#409eff1f;border:1px solid #409EFF;border-radius:2px;pointer-events:none;position:fixed;z-index:900}body.drag-moving{-webkit-user-select:none;user-select:none}.drag-ghost{background:#fff;border:1px solid #C6E2FF;border-radius:4px;box-shadow:0 4px 12px #0000002e;color:#303133;font-size:12px;max-width:260px;overflow:hidden;padding:4px 8px;pointer-events:none;position:fixed;text-overflow:ellipsis;white-space:nowrap;z-index:950}table tbody tr.drop-target td{box-shadow:inset 0 0 0 2px #409eff}.grid-view .grid-item.drop-target{border-radius:4px;outline:2px solid #409EFF;outline-offset:2px}.filename-measurer{left:-10000px;position:absolute;top:0;visibility:hidden}
|