archive-ccc8458/README.md 13.5 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 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](./video/sianel_logo.png)

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.