Repository navigation
Expand file tree
/
Copy pathStoredFile.php
More file actions
191 lines (168 loc) · 6.42 KB
/
Copy pathStoredFile.php
File metadata and controls
191 lines (168 loc) · 6.42 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
<?php
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/**
* Italix Storage - StoredFile
*
* @package Italix\Storage
*/
declare(strict_types=1);
namespace Italix\Storage;
/**
* What came back after storing something.
*
* `original_name_c` is **metadata and nothing else**. It is what the visitor
* called the file, it is attacker-controlled, and it never touches the
* filesystem — the stored path comes from the content hash. Keep it to show in
* a list or to set a download filename; never to build a path.
*/
final class StoredFile
{
private string $path_c;
private int $bytes_n;
private string $sha256_c;
private string $mime_c;
private string $original_name_c;
public function __construct(
string $path_c,
int $bytes_n,
string $sha256_c,
string $mime_c,
string $original_name_c = ''
) {
$this->path_c = $path_c;
$this->bytes_n = $bytes_n;
$this->sha256_c = $sha256_c;
$this->mime_c = $mime_c;
$this->original_name_c = $original_name_c;
}
/** The path within the store — what to persist in a column. */
public function path(): string
{
return $this->path_c;
}
public function bytes_n(): int
{
return $this->bytes_n;
}
/**
* The content hash. Also the identity: storing the same bytes twice yields
* the same path, so a re-upload is free and de-duplicated.
*/
public function sha256(): string
{
return $this->sha256_c;
}
/** The **sniffed** type, never the one the browser claimed. */
public function mime(): string
{
return $this->mime_c;
}
/** What the visitor called it. Attacker-controlled; display only. */
public function original_name(): string
{
return $this->original_name_c;
}
/**
* A URL-safe slug derived from the original name — for the *public URL*,
* never for the path.
*
* The two are different concerns, and keeping them apart is what lets a
* store this strict still produce readable links:
*
* /media/9f2c8a1b/summer-vacation-beach-sunset.jpg
* ↑ the truth ↑ decoration
*
* The route looks the file up by the hash prefix and **never reads the
* slug**. Change the slug and the file still resolves — or answer 301 to
* the canonical form, which is what a search engine prefers anyway. Because
* the lookup ignores it, a slug can carry anything without becoming an
* attack surface: it is output, and output is `Italix\Encode`'s problem.
*
* Honest about the benefit: a filename is a **weak** signal for image
* search. Alt text, surrounding copy and the page title matter more. This
* is worth having because "summer-vacation-beach-sunset.jpg" is also better for
* anyone reading a URL, a log line or a download dialog.
*/
public function slug(int $max_n = 60): string
{
$slug = self::slugify(self::without_extension($this->original_name_c), $max_n);
// A file with no usable name still needs one: the hash prefix is
// meaningless to a reader but stable and never empty.
return $slug !== '' ? $slug : substr($this->sha256_c, 0, 12);
}
/**
* `slug.ext` — what to put at the end of a public URL.
*/
public function public_slug(int $max_n = 60): string
{
$extension = pathinfo($this->path_c, PATHINFO_EXTENSION);
return $this->slug($max_n) . ($extension === '' ? '' : '.' . $extension);
}
/**
* The short, stable handle a route looks the file up by.
*
* Twelve hex characters of the content hash: enough that a collision is not
* a practical concern, short enough to sit in a URL without dominating it.
*/
public function handle_token(): string
{
return substr($this->sha256_c, 0, 12);
}
/**
* Latin letters, digits and single dashes. Nothing else.
*
* Accents are transliterated through an explicit map rather than
* `iconv('ASCII//TRANSLIT')`, which is locale-dependent and on some systems
* turns "à" into "'a" — a quote in a filename, which is the opposite of the
* point. An explicit map is longer and does the same thing everywhere.
*/
public static function slugify(string $text, int $max_n = 60): string
{
static $map = [
'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a', 'ā' => 'a',
'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e', 'ē' => 'e',
'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i', 'ī' => 'i',
'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o', 'ø' => 'o', 'ō' => 'o',
'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u', 'ū' => 'u',
'ç' => 'c', 'ñ' => 'n', 'ý' => 'y', 'ÿ' => 'y',
'ß' => 'ss', 'æ' => 'ae', 'œ' => 'oe', 'ð' => 'd', 'þ' => 'th',
'ł' => 'l', 'š' => 's', 'ž' => 'z', 'č' => 'c', 'ř' => 'r', 'ě' => 'e',
];
$text = mb_strtolower(trim($text));
$text = strtr($text, $map);
$text = (string) preg_replace('~[^a-z0-9]+~u', '-', $text);
$text = trim($text, '-');
if ($max_n > 0 && mb_strlen($text) > $max_n) {
$text = mb_substr($text, 0, $max_n);
// Cut at a dash so the slug does not end mid-word.
$last = mb_strrpos($text, '-');
if ($last !== false && $last > $max_n / 2) {
$text = mb_substr($text, 0, $last);
}
}
return trim($text, '-');
}
private static function without_extension(string $name_c): string
{
$dot = mb_strrpos($name_c, '.');
return $dot === false || $dot === 0 ? $name_c : mb_substr($name_c, 0, $dot);
}
/**
* @return array<string, mixed> for storing alongside the path
*/
public function to_array(): array
{
return [
'path_c' => $this->path_c,
'bytes_n' => $this->bytes_n,
'sha256_c' => $this->sha256_c,
'mime_c' => $this->mime_c,
'original_name_c' => $this->original_name_c,
'slug_c' => $this->slug(),
];
}
}