archive-ccc8458/README.md
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
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392

Sianel (Welsh: Channel) is a no-database minimal config PHP video host for uploading and sharing videos instead of using YouTube. It's designed to run on basic PHP hosting (built and tested on Hostinger) without needing any complex setup. Just upload via a web dashboard (or ftp, ssh etc). A single password protects upload / list / delete; playback URLs are public and shareable.
Replace `yourdomain` and `/home/<your-id>/` throughout this README with your own values.
## File overview
```
/
├── README.md
├── server-private/
│ └── video-password.php
├── sianel_logo.png
└── video
├── .htaccess
├── admin.php
├── api
│ ├── chunked
│ │ ├── abort.php
│ │ ├── finalize.php
│ │ ├── init.php
│ │ └── put.php
│ ├── delete.php
│ ├── list.php
│ └── upload.php
├── embed.php
├── index.php
├── lib
│ ├── auth.php
│ ├── chunked.php
│ ├── config.php
│ └── hash.php
├── login.php
├── play.php
├── sianel_logo.png
├── sianel.css
└── videos
```
The password file lives **above** `public_html/`, so even if PHP ever stops running it's still unreachable over HTTP — Apache will not serve files outside the webroot.
## Deployment steps
### 1. Generate a password
Open `server-private/video-password.php` and replace `PASSWORD` with a long random string (20+ characters).
Anything reasonably long works — for example, run `openssl rand -base64 24` locally and use that.
### 2. Find your home directory
Shared hosting control panels (hPanel, cPanel, etc.) often hide the real absolute path behind an obfuscated File Manager URL. The reliable way to find it is to run a one-line PHP script inside `public_html`.
Upload this file as `public_html/whereami.php`:
```php
<?php echo dirname(__DIR__);
```
Visit `https://yourdomain/whereami.php`. It will print something like:
```
/home/u123456789/domains/yourwebsite.com
```
That's the directory that contains `public_html`. The `video-password.php` file goes there. **Delete `whereami.php` afterwards.**
On Hostinger specifically, the path follows the pattern `/home/u<numeric-id>/domains/<your-domain>/`.
### 3. Edit `video/lib/config.php`
Set `PASSWORD_FILE` to the absolute path the probe revealed, plus the filename:
```php
const PASSWORD_FILE = '/home/u123456789/domains/yourwebsite.com/video-password.php';
```
Also set `SITE_BASE` (a few lines down) to your domain:
```php
const SITE_BASE = 'https://yourwebsite.com';
```
### 4. Upload via SFTP / File Manager
- `video-password.php` → `/home/u<id>/domains/yourdomain/` (the directory the probe revealed — sibling of `public_html`, **not inside it**).
- Contents of `video/` → `/home/u<id>/domains/yourdomain/public_html/video/`.
### 5. Verify
Visit `https://yourdomain/video/upload`, type your password, pick an mp4. You should see a returned `https://yourdomain/video/<hash>` URL that plays. The password is remembered in your browser's localStorage; "Forget password" clears it.
If auth fails with the right password, see the **Authorization header passthrough** note below.
## URL map
| URL | What it is |
|---|---|
| `/video/upload` | Web UI: upload, list, delete |
| `/video/api/upload` | `POST` multipart, `Authorization: Bearer <password>` — single-request upload |
| `/video/api/chunked/init` | `POST` JSON — start a resumable chunked upload |
| `/video/api/chunked/put` | `POST` raw bytes — upload one chunk |
| `/video/api/chunked/finalize` | `POST` — reassemble + commit (multipart if a thumbnail is included) |
| `/video/api/chunked/abort` | `POST` — discard a partial upload |
| `/video/api/list` | `GET` JSON, `Authorization: Bearer <password>` |
| `/video/api/delete` | `POST` `hash=...`, `Authorization: Bearer <password>` |
| `/video/<hash>` | Full playback page (public) |
| `/video/<hash>/embed` | Minimal page for iframing (public) |
## HTTP API
All API endpoints require the password as a bearer token:
```
Authorization: Bearer <your-password>
```
The same header works for `XMLHttpRequest`, `fetch`, `curl -H`, OkHttp `.header()`, etc.
Responses are always JSON. On error, expect `{"error": "human-readable message", ...optional extra fields}` with a non-2xx status code.
### `POST /video/api/upload`
Upload a video file, with an optional thumbnail image stored alongside it.
**Request:** `multipart/form-data` with these parts:
| Field | Required | Type | Notes |
|---|---|---|---|
| `file` | yes | binary | The video. MIME must be `video/mp4`, `video/webm`, `video/quicktime`, or `video/x-matroska`. Max 100 MB by default (configurable in `config.php` + your PHP `upload_max_filesize`). |
| `thumbnail` | no | binary | An image to display alongside the video. MIME must be `image/jpeg`, `image/png`, or `image/webp`. Max 5 MB. Stored in the same directory as the video as `<hash>.{jpg,png,webp}`. |
The server sniffs MIME types with PHP's `finfo` — the `Content-Type` you send is not trusted.
**Response 200:**
```json
{
"hash": "a7Kp2x4d9e",
"url": "https://yourdomain/video/a7Kp2x4d9e",
"embed": "https://yourdomain/video/a7Kp2x4d9e/embed",
"file": "https://yourdomain/video/videos/a7Kp2x4d9e.mp4",
"thumbnail": "https://yourdomain/video/videos/a7Kp2x4d9e.jpg"
}
```
`thumbnail` is `null` if none was uploaded. If a thumbnail is provided but fails validation, the whole upload is rolled back (the video is deleted too) — partial saves never happen.
**Error responses:**
| Status | Meaning |
|---|---|
| 400 | Missing/invalid `file`, or other malformed request |
| 401 | Bad or missing bearer token |
| 405 | Wrong HTTP method |
| 413 | File or thumbnail too large |
| 415 | Unsupported MIME type (response includes `"detected"` field) |
| 429 | Rate-limited (5 bad auth attempts → 15-minute lockout per IP) |
| 500 | Server filesystem error |
**curl example:**
```bash
curl -X POST https://yourdomain/video/api/upload \
-H "Authorization: Bearer YOUR-PASSWORD" \
-F "file=@./clip.mp4;type=video/mp4" \
-F "thumbnail=@./clip.jpg;type=image/jpeg"
```
### Chunked upload (large files)
For files larger than your PHP `post_max_size` / `upload_max_filesize`, or for resumable uploads on flaky networks, use the chunked-upload endpoints. Total files up to **2 GB** are supported by default (`MAX_CHUNKED_BYTES` in `config.php`). Per-chunk size is capped at **16 MB** by default (`MAX_CHUNK_BYTES`); each chunk's POST still has to fit within your PHP upload limits.
The flow is:
```
POST /video/api/chunked/init → returns upload_id, expected_chunks
POST /video/api/chunked/put?id=…&index=0 (raw chunk bytes)
POST /video/api/chunked/put?id=…&index=1 (raw chunk bytes)
…
POST /video/api/chunked/finalize → returns hash, url, embed
```
Partial uploads are kept under `/video/.uploads/<upload_id>/` (not web-accessible) and garbage-collected after 24 h (`UPLOAD_TTL_SEC`).
#### `POST /video/api/chunked/init`
Start a chunked upload session.
**Request body** (`application/json` or `application/x-www-form-urlencoded`):
| Field | Required | Type | Notes |
|---|---|---|---|
| `filename` | yes | string | Original filename, ≤ 255 chars, no `/`, `\`, or NUL bytes. |
| `mime` | yes | string | Must be one of `video/mp4`, `video/webm`, `video/quicktime`, `video/x-matroska`. Re-checked server-side after reassembly. |
| `total_size` | yes | integer | Total file size in bytes. Must be ≤ `MAX_CHUNKED_BYTES`. |
| `chunk_size` | yes | integer | Bytes per chunk. Must be ≤ `MAX_CHUNK_BYTES`. Every chunk except the last must be exactly this size. |
**Response 200:**
```json
{
"upload_id": "5b2f...32 hex chars total",
"chunk_size": 4194304,
"expected_chunks": 38,
"put_url": "https://yourdomain/video/api/chunked/put",
"finalize_url": "https://yourdomain/video/api/chunked/finalize",
"abort_url": "https://yourdomain/video/api/chunked/abort"
}
```
#### `POST /video/api/chunked/put?id=<upload_id>&index=<n>`
Upload one chunk. The request **body is the raw chunk bytes** — no multipart wrapping. `index` is the 0-based chunk number.
Chunks can be uploaded in any order. Re-uploading the same index overwrites it
**Response 200:**
```json
{
"upload_id": "5b2f…",
"index": 7,
"size": 4194304,
"received": 8,
"expected": 38
}
```
**Error responses:**
| Status | Meaning |
|---|---|
| 400 | Invalid `upload_id`, invalid `index`, empty body, or middle-chunk size doesn't match the `chunk_size` declared at init |
| 401 | Bad bearer token |
| 404 | No upload session with that id |
| 413 | Chunk exceeds `MAX_CHUNK_BYTES` |
| 500 | Filesystem error |
#### `POST /video/api/chunked/finalize`
Verify all chunks are present, reassemble, MIME-sniff with `finfo`, move to `/video/videos/`, and (optionally) save a thumbnail. The staging directory is deleted on success.
**Request:** can be either:
- `application/x-www-form-urlencoded` or `application/json` with `upload_id`, or
- `multipart/form-data` with an `upload_id` text field and an optional `thumbnail` file (same constraints as the standard upload endpoint).
**Response 200:** same shape as `/api/upload`:
```json
{
"hash": "a7Kp2x4d9e",
"url": "https://yourdomain/video/a7Kp2x4d9e",
"embed": "https://yourdomain/video/a7Kp2x4d9e/embed",
"file": "https://yourdomain/video/videos/a7Kp2x4d9e.mp4",
"thumbnail": null
}
```
**Error responses:**
| Status | Meaning |
|---|---|
| 400 | Invalid `upload_id` |
| 401 | Bad bearer token |
| 404 | No upload session with that id |
| 409 | One or more chunks missing (response includes `"missing": [indexes…]`), or reassembled size doesn't match the declared `total_size` |
| 413 | Thumbnail too large |
| 415 | Reassembled file's MIME isn't an allowed video type, or thumbnail MIME isn't allowed |
| 500 | Filesystem error |
#### `POST /video/api/chunked/abort`
Discard a partial upload. Idempotent — calling on an unknown id still returns 200.
**Request:** `upload_id` via form field or JSON body.
**Response 200:**
```json
{ "aborted": "5b2f…" }
```
#### Client pseudocode
```python
init = POST("/api/chunked/init", json={
"filename": "movie.mp4",
"mime": "video/mp4",
"total_size": len(file),
"chunk_size": 4 * 1024 * 1024,
})
for i in range(init["expected_chunks"]):
chunk = file[i*init["chunk_size"] : (i+1)*init["chunk_size"]]
POST(f"/api/chunked/put?id={init['upload_id']}&index={i}", body=chunk)
result = POST("/api/chunked/finalize", form={"upload_id": init["upload_id"]})
print(result["url"])
```
### `GET /video/api/list`
List all uploaded videos, newest first.
**Response 200:**
```json
{
"videos": [
{
"hash": "a7Kp2x4d9e",
"filename": "a7Kp2x4d9e.mp4",
"size": 4938421,
"uploaded": 1748100923,
"url": "https://yourdomain/video/a7Kp2x4d9e",
"embed": "https://yourdomain/video/a7Kp2x4d9e/embed",
"file": "https://yourdomain/video/videos/a7Kp2x4d9e.mp4",
"thumbnail": "https://yourdomain/video/videos/a7Kp2x4d9e.jpg"
}
]
}
```
`size` is bytes. `uploaded` is a Unix epoch in seconds (the file's mtime). `thumbnail` is `null` if no thumbnail exists.
**curl example:**
```bash
curl -H "Authorization: Bearer YOUR-PASSWORD" \
https://yourdomain/video/api/list
```
### `POST /video/api/delete`
Delete a video. The sibling thumbnail (if any) is removed best-effort.
**Request body** — either `application/x-www-form-urlencoded`:
```
hash=a7Kp2x4d9e
```
…or `application/json`:
```json
{ "hash": "a7Kp2x4d9e" }
```
**Response 200:**
```json
{ "deleted": "a7Kp2x4d9e" }
```
**Error responses:**
| Status | Meaning |
|---|---|
| 400 | Missing or malformed hash |
| 401 | Bad bearer token |
| 404 | No video with that hash |
| 405 | Wrong HTTP method |
| 500 | Filesystem error during unlink |
**curl example:**
```bash
curl -X POST https://yourdomain/video/api/delete \
-H "Authorization: Bearer YOUR-PASSWORD" \
-d "hash=a7Kp2x4d9e"
```
## Security properties
- Password file lives outside `public_html`, so it is never web-accessible — even if PHP stops working.
- HTTPS enforced at the `.htaccess` layer; APIs also re-check on every request.
- Per-IP rate limit: after 5 failed auth attempts there's a 15-min lockout.
- Constant-time password comparison (`hash_equals`).
- Upload MIME sniffed server-side with `finfo`, not trusted from the client.
- Uploads directory has `Options -Indexes` and denies PHP execution.
- Filenames are 40-bit random hex (with a retry on collision); uploaders can't pick the URL.
## PHP Notes
- **`php_value` in .htaccess** works on most Hostinger plans but some use PHP-FPM and silently ignore the directives. If a 100 MB upload returns 500 or an empty response, set `upload_max_filesize` / `post_max_size` in hPanel → Advanced → PHP Configuration instead.
- **Authorization header passthrough**: some Hostinger/Apache configs strip the `Authorization` header before PHP sees it. The bundled `.htaccess` re-injects it as `HTTP_AUTHORIZATION` and `auth.php` reads both. If auth still fails after the password is correct, drop a `phpinfo()` script in `public_html` and look for the header to confirm it's arriving.
- **`open_basedir` restrictions** — on shared hosts, PHP can be locked to a specific directory tree. The "find your home directory" probe (step 2) tells you a path that's definitely inside `open_basedir`. Putting the password file higher up will silently fail.