Repository navigation
Expand file tree
/
Copy pathpage.py
More file actions
284 lines (252 loc) · 12.1 KB
/
Copy pathpage.py
File metadata and controls
284 lines (252 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
"""
The one page this app draws: a form, and a table of what has been uploaded.
**Plain HTML in a string, no framework and no build step.** You can read the whole thing,
and `pip install -r requirements.txt` is the whole of setting it up. Swapping this for
Jinja or a front-end framework is a perfectly good exercise; it is left out so that
nothing between your code and the page needs explaining first.
"""
from datetime import datetime, timezone
def safe(said):
"""
**Escaped before it goes in, every time.** A filename arrives from somebody's computer
and goes back out onto a page, which is the exact shape of a cross-site scripting hole:
a file called `<img src=x onerror=alert(1)>` would otherwise run as markup in the next
person's browser. There is no clever version of this rule: escape it, always.
"""
return (("" if said is None else str(said))
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
.replace("'", "'"))
def said_size(size):
"""Bytes, said the way a person reads them."""
n = size or 0
if n < 1024:
return f"{n} B"
if n < 1024 * 1024:
return f"{n / 1024:.1f} KB"
return f"{n / (1024 * 1024):.1f} MB"
def kind_of(name):
"""A short label for what kind of file this is, from its name: shown, never trusted."""
name = "" if name is None else str(name)
dot = name.rfind(".")
ext = name[dot + 1:].lower() if dot > 0 else ""
# Length in UTF-16 units, as the browser-side version of this page counts it.
units = len(ext.encode("utf-16-le")) // 2
return ext.upper() if ext and units <= 4 else "FILE"
def said_date(when):
"""
The time as day/month/year and a 24-hour clock, always in UTC, for example
`12/10/2026, 14:05:09`. **UTC on purpose**: the server's own zone depends on where it
happens to run, and a page that says a different time after a move is a surprise.
"""
when = when.astimezone(timezone.utc)
return when.strftime("%d/%m/%Y, %H:%M:%S")
# **One stylesheet, in the page, with the colours as variables.** Every colour is named
# once at the top and again for dark mode, so changing the look is changing a handful of
# lines rather than hunting through rules. No framework: everything here is CSS a browser
# has understood for years.
STYLE = """
:root {
color-scheme: light dark;
--bg: #f5f7fb; --card: #ffffff; --text: #0f172a; --muted: #64748b;
--line: #e2e8f0; --accent: #4f46e5; --accent-soft: #eef2ff; --accent-text: #ffffff;
--danger: #dc2626; --danger-soft: #fef2f2; --ok: #059669;
--shadow: 0 1px 2px rgb(15 23 42 / .06), 0 8px 24px rgb(15 23 42 / .06);
--radius: 14px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b1120; --card: #111827; --text: #e5e7eb; --muted: #94a3b8;
--line: #1f2937; --accent: #818cf8; --accent-soft: #1e1b4b; --accent-text: #0b1120;
--danger: #f87171; --danger-soft: #2a1215; --ok: #34d399;
--shadow: 0 1px 2px rgb(0 0 0 / .4), 0 8px 24px rgb(0 0 0 / .3);
}
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font: 16px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
main { max-width: 56rem; margin: 0 auto; padding: 3rem 1rem 4rem; }
header { display: flex; gap: 1rem; align-items: center; margin-bottom: 2rem; }
.logo {
width: 3rem; height: 3rem; border-radius: 12px; flex: none; display: grid;
place-items: center; background: var(--accent); color: var(--accent-text);
box-shadow: var(--shadow);
}
h1 { font-size: 1.75rem; line-height: 1.2; margin: 0; letter-spacing: -.01em; }
.lead { margin: .25rem 0 0; color: var(--muted); }
.card {
background: var(--card); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 1.5rem; margin-bottom: 1.5rem;
}
.card h2 {
font-size: 1rem; margin: 0 0 1rem; display: flex; justify-content: space-between;
align-items: baseline;
}
.count { font-weight: 500; color: var(--muted); font-size: .9rem; }
/* The drop zone is the file input itself, stretched over the box and made invisible, so
clicking anywhere or dropping a file onto it is the browser's own behaviour. */
.drop {
position: relative; border: 2px dashed var(--line); border-radius: 12px;
padding: 2rem 1rem; text-align: center; transition: border-color .15s, background .15s;
}
.drop:hover, .drop:focus-within, .drop.over {
border-color: var(--accent); background: var(--accent-soft);
}
.drop input[type=file] {
position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer;
}
.drop svg { color: var(--accent); }
.drop strong { display: block; margin-top: .5rem; }
.drop .hint { color: var(--muted); font-size: .9rem; }
.drop .chosen { margin-top: .5rem; font-weight: 600; color: var(--ok); min-height: 1.5em; }
.actions { display: flex; justify-content: flex-end; margin-top: 1rem; }
.btn {
font: inherit; font-weight: 600; font-size: .95rem; border-radius: 10px;
padding: .55rem 1rem; border: 1px solid transparent; cursor: pointer;
display: inline-flex; align-items: center; gap: .4rem; text-decoration: none;
transition: filter .15s, background .15s;
}
.btn-primary { background: var(--accent); color: var(--accent-text); }
.btn-primary:hover { filter: brightness(1.08); }
.btn-ghost { background: transparent; color: var(--text); border-color: var(--line); }
.btn-ghost:hover { background: var(--accent-soft); }
.btn-danger { background: transparent; color: var(--danger); border-color: var(--line); }
.btn-danger:hover { background: var(--danger-soft); }
.btn-sm { padding: .35rem .7rem; font-size: .85rem; }
.btn:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.trouble {
padding: .85rem 1rem; border-radius: 10px; margin-bottom: 1.5rem;
background: var(--danger-soft); color: var(--danger); border: 1px solid var(--danger);
}
ul.files { list-style: none; margin: 0; padding: 0; }
ul.files li {
display: flex; align-items: center; gap: 1rem; padding: .85rem 0;
border-top: 1px solid var(--line);
}
ul.files li:first-child { border-top: 0; }
.badge {
flex: none; width: 3rem; height: 3rem; border-radius: 10px; display: grid;
place-items: center; font-size: .7rem; font-weight: 700; letter-spacing: .04em;
background: var(--accent-soft); color: var(--accent);
}
.meta { flex: 1; min-width: 0; }
.name { font-weight: 600; overflow-wrap: anywhere; color: var(--text); text-decoration: none; }
.name:hover { color: var(--accent); }
.sub { color: var(--muted); font-size: .85rem; }
.row-actions { display: flex; gap: .5rem; flex: none; }
.row-actions form { margin: 0; }
.empty { text-align: center; padding: 2rem 1rem; color: var(--muted); }
.empty svg { color: var(--line); }
.how { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.how div { padding: 1rem; border-radius: 12px; background: var(--bg); }
.how b { display: block; margin-bottom: .25rem; }
.how span { color: var(--muted); font-size: .9rem; }
footer { text-align: center; color: var(--muted); font-size: .85rem; margin-top: 2rem; }
@media (max-width: 40rem) {
main { padding-top: 2rem; }
.how { grid-template-columns: 1fr; }
ul.files li { flex-wrap: wrap; }
.row-actions { width: 100%; justify-content: flex-end; }
}
"""
# Icons drawn inline, so the page needs nothing from anywhere else.
ICON = {
"cloud": '<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17.5 19a4.5 4.5 0 1 0-1.4-8.8A6 6 0 1 0 6 17.9"/><path d="M12 12v9"/><path d="m8 16 4-4 4 4"/></svg>',
"upload": '<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/></svg>',
"empty": '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>',
}
# **The one piece of script on the page, and the page works without it.** It says which
# file was picked and lights the box up while a file is dragged over it; the form posts
# exactly the same either way.
SCRIPT = """
const input = document.getElementById('file');
const box = input.closest('.drop');
const chosen = box.querySelector('.chosen');
input.addEventListener('change', () => {
chosen.textContent = input.files.length ? input.files[0].name : '';
});
['dragenter', 'dragover'].forEach((e) => input.addEventListener(e, () => box.classList.add('over')));
['dragleave', 'drop'].forEach((e) => input.addEventListener(e, () => box.classList.remove('over')));
"""
def page(files, trouble=""):
rows = "".join(f"""
<li>
<div class="badge" aria-hidden="true">{safe(kind_of(file["name"]))}</div>
<div class="meta">
<a class="name" href="/files/{safe(file["id"])}">{safe(file["name"])}</a>
<div class="sub">{safe(said_size(file["size_bytes"]))} ·
{safe(said_date(file["uploaded_at"]))}</div>
</div>
<div class="row-actions">
<a class="btn btn-ghost btn-sm" href="/files/{safe(file["id"])}">Download</a>
<form method="post" action="/files/{safe(file["id"])}/delete">
<button type="submit" class="btn btn-danger btn-sm"
aria-label="Delete {safe(file["name"])}">Delete</button>
</form>
</div>
</li>""" for file in files)
alert = f'<p class="trouble" role="alert">{safe(trouble)}</p>' if trouble else ""
count = f'{len(files)} {"file" if len(files) == 1 else "files"}'
if len(files) == 0:
listing = (f'<div class="empty">{ICON["empty"]}'
'<p>Nothing uploaded yet. Your files will appear here.</p></div>')
else:
listing = f'<ul class="files">{rows}</ul>'
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>File uploads</title>
<style>{STYLE}</style>
</head>
<body>
<main>
<header>
<div class="logo">{ICON["cloud"]}</div>
<div>
<h1>File uploads</h1>
<p class="lead">The file goes to S3. Where it went goes to Postgres. Neither store
knows about the other — this app is what joins them.</p>
</div>
</header>
{alert}
<section class="card">
<h2>Upload a file</h2>
<form method="post" action="/upload" enctype="multipart/form-data">
<div class="drop">
{ICON["upload"]}
<!-- A visible label, because a control named only by its placeholder is a
control a screen reader cannot announce. -->
<label for="file"><strong>Drop a file here, or click to choose one</strong></label>
<div class="hint">Anything up to 10 MB</div>
<div class="chosen" aria-live="polite"></div>
<input type="file" name="file" id="file" required>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary">Upload it</button>
</div>
</form>
</section>
<section class="card">
<h2>Your files <span class="count">{count}</span></h2>
{listing}
</section>
<section class="card">
<h2>How it works</h2>
<div class="how">
<div><b>1. Your browser</b><span>sends the file to this app as a form post.</span></div>
<div><b>2. S3</b><span>keeps the bytes, under a random key nobody can guess.</span></div>
<div><b>3. Postgres</b><span>remembers the name, the size and where the bytes went.</span></div>
</div>
</section>
<footer>A sample app. Read the code, change it, deploy it again.</footer>
</main>
<script>{SCRIPT}</script>
</body>
</html>"""