# ESLfinder.vn

Teacher directory for Vietnam. Static front end, PHP + SQLite back end.

Teachers register, upload a verification pack, and appear in a public
directory under a reference code. Schools browse and enquire by
reference. Staff verify documents from a separate page.

---

## What's in the box

```
public/                  <- this is your document root
  index.html             the whole site (HTML, CSS, JS in one file)
  staff.php              verification desk, staff accounts only
  .htaccess              security headers, /ref/ links, dotfile blocking
  api/
    install.php          run once, then delete
    config.php           the only file you normally edit
    _boot.php            error handling + session for every endpoint
    db.php               schema
    helpers.php          sessions, CSRF, encryption, validation
    signup.php           create account + profile + verification pack
    login.php            sign in (rate limited)
    logout.php
    me.php               session state + CSRF token
    teachers.php         public directory feed
    featured.php         paid placement on/off
    enquiry.php          school enquiries
    doc.php              the only way to read a verification document
    photo.php            upload / replace / remove the profile photo
    avatar.php           serves profile photos
    deactivate.php       teacher removes their own listing

private/                 <- NOT web reachable. Move it above the docroot.
  uploads/               encrypted documents
  (secret.key and eslfinder.sqlite are created by the installer)

bin/
  purge.php              daily retention job
  migrate.php            idempotent schema update, run after an upgrade
```

## Requirements

PHP 8.0 or newer with `pdo_sqlite`, `openssl` and `fileinfo`. All three
are standard. `mbstring` is used if present and polyfilled if not.

No Composer, no build step, no MySQL to set up.

---

## Install

### 1. Upload

Put `public/` contents in your document root and `private/` **above**
it. On cPanel that looks like:

```
/home/youracct/private/          <- private/ from this bundle
/home/youracct/public_html/      <- contents of public/
```

`api/config.php` looks for `private` one level above the document root,
so that layout needs no edit. Anywhere else, set the path at the top of
`config.php`.

### 2. Run the installer

Visit `https://yoursite.vn/api/install.php` once, or run
`php public/api/install.php` over SSH. It creates the folders, generates
the encryption key, builds the tables and prints a staff login.

**Write that password down — it is shown once.**

### 3. Delete `api/install.php`

It refuses to run twice, but delete it anyway.

### 4. Add the cron job

```
0 3 * * *  /usr/bin/php /home/youracct/bin/purge.php
```

Erases verification packs 90 days after a teacher deactivates, and trims
old logs. Without it, documents accumulate forever.

### 5. Back up the key separately

`private/secret.key` decrypts every passport number and document. Keep a
copy somewhere other than wherever the database backup lives. If key and
database sit in the same backup, one stolen backup gives up both. If you
lose the key, the documents are gone — there is no recovery.

---

## How the sensitive data is handled

You asked originally for a folder per teacher with a `.txt` of their
details next to their scans. This does the same job differently, because
that layout fails badly the first time anything goes wrong.

| | How it works here |
|---|---|
| Where documents live | `private/uploads/`, outside the document root, never served directly |
| File names | 16 random bytes. Nothing links a filename to a person. |
| File contents | AES-256-GCM encrypted. A copied uploads folder is unreadable without the key. |
| Passport number | Encrypted in the database. Only the last 3 digits stay readable, so support can confirm a caller without decrypting anything. |
| Reading a document | Only `api/doc.php`, which checks you are the owner or staff, and records who looked, when, and from where |
| File type checking | Detected from content, not from the filename or the browser's claim. Images must decode as images. |
| Passwords | `password_hash()` bcrypt, with rehash on algorithm upgrade |
| Login | 8 failures per IP per 15 minutes |
| Forms | CSRF token required on every POST |
| Deletion | Teacher deactivates, documents are overwritten then unlinked after 90 days |

Vietnam's Decree 13/2023 treats ID numbers and facial images as sensitive
personal data, with consent, security and deletion obligations. The
consent checkbox at signup is recorded with a timestamp and IP in
`private_records`, which is the evidence you would need if asked.

Two things this does **not** do, which you should add before launch:

- **HTTPS.** Everything above is undone if the login form posts over
  plain HTTP. Use Let's Encrypt, free on every host worth using.
- **A privacy notice and terms page.** The footer links are stubs.

---

## Profile photos

Two different pictures, doing two different jobs. Keeping them apart is
deliberate.

**Verification selfie** — part of the private pack. Collected on the
promise that it stays private until the teacher accepts an
introduction. Never published, never used as the profile photo.
Reusing it publicly would break the consent it was collected under.

**Profile photo** — optional, public, and entirely the teacher's
choice. Uploaded at signup or any time afterwards from the account
page, replaceable, removable. When there is no photo the card falls
back to initials.

Every uploaded photo is **re-encoded** with GD before it is stored:
rotated upright from its EXIF orientation, resized to 800px on the
longest edge, written out as a fresh JPEG. That last part matters more
than it sounds. A photo taken on a phone usually carries EXIF metadata
including the GPS coordinates of where it was taken, and for a selfie
that is generally someone's home address. Rebuilding the image from
pixels alone leaves all of it behind. A 1600x1200 test photo carrying
GPS tags came back out 800x600 with no metadata at all.

Photos are stored exactly like the private documents — encrypted,
outside the document root, random filenames — and served through
`avatar.php`, which checks visibility before decrypting. There is no
direct URL to the file. The URL carries a version number that changes
when the photo does, so browsers never show a stale picture after a
change.

Replacing a photo erases the old file rather than orphaning it.

### The trade-off you should think about

A face plus a city plus a credential list is recognisable. A school
that wants to go around you can reverse-image search a photo, or simply
recognise someone they have met. A public photo makes the directory
warmer and it does weaken the poaching protection — those pull in
opposite directions and no setting resolves it.

So teachers get a switch rather than a rule: the photo can be shown on
the card, or held back until they accept an introduction. It defaults
to shown. If poaching turns out to be a real problem once you are
running, flipping the default is a one-line change in
`api/signup.php`.

## Featured placement

`config.php` has `FEATURED_AUTO_GRANT`. It ships as `true`, so toggling
featured in the account page works immediately — good for demos.

**Set it to `false` before you take real money.** Then an order row is
written with status `pending` and featured stays off until payment is
confirmed. Wire your provider's webhook (VNPay, MoMo, Stripe) to call
`featured_grant()` in `api/featured.php` after it verifies the callback
signature. Never grant from the browser.

Price and duration are `FEATURED_PRICE_VND` and `FEATURED_DAYS`.

---

## Staff desk

`https://yoursite.vn/staff.php`, for accounts with role `staff` or
`admin`. Shows every teacher with their real name, country, passport
last-3 and links to their documents, and buttons to mark verified or
hide a profile.

Promote someone to staff:

```sql
UPDATE users SET role='staff' WHERE email='name@yoursite.vn';
```

Every document opened from this page is written to `doc_access_log`
against that staff member. Tell your team that — it is the reason nobody
browses passports out of curiosity.

---

## API

All POSTs need the `X-CSRF-Token` header, from `GET api/me.php`.

| Endpoint | Method | Notes |
|---|---|---|
| `api/me.php` | GET | session state, CSRF token. Call first. |
| `api/teachers.php` | GET | public feed. `?region= &city= &type= &move=1 &q=` |
| `api/signup.php` | POST | multipart. Files: `passport`, `selfie`, `certificate[]` |
| `api/login.php` | POST | `email`, `password` |
| `api/logout.php` | POST | |
| `api/featured.php` | POST | `action=start` or `action=cancel` |
| `api/enquiry.php` | POST | school enquiry, honeypot field `website` |
| `api/deactivate.php` | POST | teacher removes own listing |
| `api/doc.php?id=N` | GET | verification docs. Owner or staff only, logged |
| `api/photo.php` | POST | `action=upload` (field `photo`), `remove`, or `visibility` with `public=0\|1` |
| `api/avatar.php?ref=X` | GET | profile photo. Public when the teacher allows it |

`teachers.php` returns reference code, initials, city, region, hours,
relocation, years, focus, credentials, verified and featured flags. It
never returns a name, email, country, passport or document.

---

## Local testing

```
php -S 127.0.0.1:8080 -t public
```

Then open `http://127.0.0.1:8080`. Opening `index.html` straight off the
disk also works — it detects there is no server and shows twelve sample
teachers so you can review the design, but signup and sign-in need PHP.

---

## Switching to MySQL

SQLite is fine into the thousands of profiles. If you outgrow it, the
only file to change is `db.php`: swap the DSN and change
`INTEGER PRIMARY KEY AUTOINCREMENT` to `INT AUTO_INCREMENT PRIMARY KEY`,
and `datetime('now')` to `NOW()`. Nothing else touches the database
directly.
