# PostDoc Application Portal — Reference Deployment

A self-contained PostDoc application portal: applicants submit online,
referees upload letters, a committee reviews and rates, and staff manage
the process through a unified login covering four roles — admin,
committee, HR, and disability officer (SBV). No database required (plain
JSON files), no Composer, no dependency on institutional SSO/Kerberos —
just PHP and a web server.

This was built at the Max Planck Institute for Astrophysics (MPA) as
`pd2026`. All MPA-specific branding, contacts, and network restrictions
have been stripped out of this package; what's left is meant as a working
starting point for another institute, not a polished off-the-shelf
product. Expect to read the code.

## What's in this tar

Everything is inside one `pdportal/` folder, so extracting the tar doesn't
scatter a dozen loose files into your Downloads folder:

```
pdportal/
  README.md               this file
  setup.php                browser-based installer — no command line needed
  deploy.php               command-line installer, if you'd rather use that
  install_lib.php          shared logic — needed by both installers, not by you
  auth/                    unified login (admin / committee / hr / sbv)
  portal/                  the application portal itself
  storage/
    *.php, *.py            optional tools (test-data generators, PDF
                            merging, full-text search indexing) — none of
                            these are required for the portal to run
    *-secrets.php.sample   SMTP credential templates
  tests/                   small HTTP-level test suite — see "Testing" below
```

`auth/` and `portal/` are two independent-but-connected apps that share
one login. `auth/` doesn't know or care what `portal/` is called or where
it lives beyond the URL in `applications.php`; you could in principle run
multiple portals off one `auth/` install, though that's untested territory
here.

## Prerequisites

- PHP 8.1+ with the `session`, `json`, `mbstring`, and `fileinfo`
  extensions (all standard on most distro PHP packages).
- A web server (Apache assumed below — the `.htaccess` files use
  `Options`/`DirectoryIndex`/`<Files>` directives, so `AllowOverride All`
  needs to be set for the directories they live in).
- `qrencode` for TOTP/2FA QR codes: `apt install qrencode` on
  Debian/Ubuntu, typically lands at `/usr/bin/qrencode`. Without it, 2FA
  setup fails quietly (logged, not fatal) — logins still work, just
  without a scannable QR code.
- SMTP credentials for outgoing mail (activation links, applicant/referee
  notifications, password resets). Any standard SMTP provider works.
- Optional, only if you'll use the `storage/` test-data generators, the
  PDF-merging tool (`unitepdf.py`), or the full-text search indexer
  (`build_search_index.py`) — all three are wired into the admin "Combine
  PDFs" page, run there together:
  - Python 3.
  - `poppler-utils` (`apt install poppler-utils` on Debian/Ubuntu) for the
    `pdfunite`/`pdftotext` binaries both scripts shell out to.
  - `ghostscript` (`apt install ghostscript`) for the `gs` repair step
    `unitepdf.py` runs after merging.
  - Nothing extra for the search indexer's SQLite/FTS5 support — it uses
    Python's stdlib `sqlite3` module, which on Debian/Ubuntu already links
    against an FTS5-enabled `libsqlite3`. It only needs `pip install
    pysqlite3-binary` as a fallback on platforms whose stdlib sqlite3
    lacks FTS5 (mainly macOS); the script detects this itself and prints
    that instruction if it hits it.
  - Nothing extra for `unitepdf.py`'s cover-page generation either —
    the installer already places `tfpdf.php` and the CMU Unicode font
    (`font/unifont/*.ttf`) it needs in `<storage>/pd2026/`. The first time
    it actually runs, it generates its own font-metric cache files
    (`font/unifont/*.mtx.php`/`*.cw.dat`) — these bake in an absolute
    path, so they're deliberately *not* shipped pre-built (a cache built
    on one machine is wrong on another); they're harmless to delete if
    you ever move the storage directory, since they just regenerate on
    next use.

## Install

**No command line needed:** copy everything inside `pdportal/` (the
`auth/`, `portal/`, `storage/` folders, `setup.php`, `README.md` — all of
it) directly into your web server's document root, then visit
`https://your-site/setup.php` in a browser. It checks your PHP
version/extensions and the external tools the "Combine PDFs" page needs,
then asks for one thing it can't guess — a storage directory path
*outside* your document root, writable by your web server's user (this is
where applicant data, uploads, and accounts live; it must never be
reachable by URL) — plus a username for your first admin account. Submit
the form and it moves `storage/`'s contents there, activates the config
files, generates a working admin account, and deletes `storage/`,
`deploy.php`, `install_lib.php`, `tests/`, and itself from the document
root, since none of those should stay reachable by URL once installed.

**Prefer the command line?** Don't copy anything yourself — run this from
inside the extracted `pdportal/` folder instead, before it's anywhere near
your document root:

```bash
php deploy.php
```

It asks the same things (a target web root directory, a target storage
directory, an admin username) via prompts instead of a web form, then
copies `auth/`+`portal/` and `storage/` to the two locations you gave it.
Either installer prints the same result: a login URL, username, and
one-time password. Neither asks about institute name, branding, dates, or
SMTP — those live in the config files they generate, which you edit by
hand next.

Both installers are safe to re-run: neither overwrites a `config.php`,
`secrets.php`, or existing user account that already exists at the
target — they only fill in what's missing. (`setup.php` specifically:
re-running it only makes sense before you've submitted its form — once
`storage/` is moved and the installer files are deleted, there's nothing
left for a second run to do.)

## Configure

Every `*.php.sample` file the installer copies into a real filename is
full of inline comments explaining each value. At minimum:

- `<document root>/portal/config.php` — institute name, program title,
  contact info, application period dates, logo filename.
- `<document root>/auth/config.php` — the login system's own URL and
  QR-code issuer label.
- `<storage>/pd2026/secrets.php` and `<storage>/extusers/secrets.php` —
  SMTP credentials. Until these are real, mail fails silently (logged,
  not fatal) — the portal stays usable, nothing just gets emailed out.
  **The two files use different constant names for the same values**
  (`SMTP_HOST` vs `EXT_SMTP_HOST`, etc. — see the comments in each
  `.sample` file) — mixing them up leaves the wrong app's constants
  undefined, which is *not* the graceful "logged, not fatal" case above:
  on the auth side specifically it crashes the whole page with an
  uncaught fatal error the instant anyone tries to activate an account or
  reset a password. After filling both in, log in as an admin and check
  `<your-portal-url>/admin_diagnostics.php` — it verifies both files'
  constants, CSRF_SECRET, storage permissions, and the external tools
  the "Combine PDFs" page needs, all in one place.

Also replace `<document root>/auth/logo.png` and
`<document root>/portal/logo.png` with your own logo (same filename, or
update `LOGO_PATH` in each app's own `config.php` to match whatever
filename you use).

## First login

1. Go to `<your-auth-url>/login.php` with the username/password the
   installer printed. You'll be forced to change the password and set
   up TOTP/2FA immediately — that's `must_change_password` /
   `must_setup_2fa` on the account, not optional.
2. From the dashboard, open the portal — `admin.php?token=<the admin
   token the installer printed>` gets you into the full admin view
   directly; the portal dashboard also links there once you're logged in
   with the `pd_admin` role (which your first account already has).
3. To add committee members: `createtokens.php` lets you add people to
   the roster and issue each one a personal review token. Separately,
   grant them the `committee` role via `<your-auth-url>/add_user.php` so
   they can actually log in — the two are independent (see below).
4. To add HR/SBV staff: same `add_user.php`, roles `hr` / `sbv`.

## How the roles actually work

This matters because it's not obvious from the UI alone:

- **Login** (`auth/`) is one system for everyone — password + TOTP,
  accounts in `storage/extusers/users.json`, managed via
  `add_user.php`/`edit_user.php`.
- **What a logged-in person can reach** depends on which roles they hold
  for the `pd2026` application: `pd_admin` → `admin.php` and friends;
  `committee` → `committee.php` and friends; `hr`/`sbv` → the HR/SBV
  review pages.
- **Committee access has a second layer**: beyond the `committee` role,
  each committee member also needs a personal token (issued via
  `createtokens.php`, stored in `committeetokens.json`) that has to match
  their own logged-in username — this is what stops one committee member
  from browsing into another's URL. If someone has the role but no token
  yet, they can log in but can't reach `committee.php` until you issue one.
- **Admin access has the same kind of second layer**: `admin.php` and its
  siblings also check a single shared admin token
  (`storage/pd2026/admintoken.php`, generated once by the installer) in the
  URL, on top of the `pd_admin` role. This is why the admin link is
  `admin.php?token=...` rather than just `admin.php`.

## Adding another portal (new hiring cycle)

`auth/` is meant to stay put permanently; the portal folder (`portal/`,
app key `pd2026`) is meant to be replaced or supplemented over time — a
new hiring cycle, a differently-scoped program, etc. Rather than re-running
the whole installer, a `user_admin` can create an additional portal
straight from the dashboard: **User Admin → Create New Portal**
(`auth/create_portal.php`).

Pick an existing portal to copy code and storage utility scripts
(`tfpdf.php`, `unitepdf.py`, `json_to_pdf.*`, `build_search_index.py`,
`font/`) from, give the new one a folder name/app key (e.g. `pd2027`), a
label, and institute/contact/date details — institute name, contact info,
and SMTP credentials are pre-filled from the portal you're copying (these
rarely change between cycles), while dates and labels are left for you to
fill in (these almost always do). It generates a fresh `config.php` and
`secrets.php` (own `CSRF_SECRET`, SMTP reused from the template) for the
new portal, and registers it in `storage/extusers/portals.json`, which is
what drives the dashboard listing and `add_user.php`'s role picker — no
editing PHP source required.

After creating one, nobody can reach its admin pages yet: grant the
`pd_admin` role for the new app key via `edit_user.php`, same as any other
role grant.

This is why the storage directory's *parent* should stay writable
indefinitely (see Install, above) — `create_portal.php` needs to create a
new sibling storage directory under it each time, and can't do that if
you've locked that parent back down after the initial install.

## Restricting access by network (optional)

**User Admin → Edit Network Access** (`auth/edit_denyip.php`) lets you
turn on an IP allowlist for `auth/` and/or any individual portal — the
same wildcard-IP gating the original MPA/AFS deployment used (`130.183.82.*`
style patterns, `*` matching one octet), but editable from the dashboard
instead of hand-edited PHP, and independent per portal rather than one
list for everything.

Everything is reachable from anywhere by default (every fresh install,
and every portal `create_portal.php` makes) — this is purely opt-in
hardening for institutions that want to pair the login with network
restriction, not something you need to configure.

Under the hood it's one JSON file (`AFSEXTUSERS/denyip.json`), read by
`auth/denyip_verwaltungsbv.php` and each portal's own `denyip.php`. Both
of those run *before* `config.php` on every page load (portal side) or
rely on it already being loaded (auth side, where the include order is
consistently config-then-denyip) — see the comments at the top of each
file if you're touching this logic.

## Testing

`tests/` is a small HTTP-level suite that checks the role-gating logic
above actually holds — the same things worth re-checking by hand after any
change to `auth/` or the `*_check.php` files, now automated instead.

```bash
cp tests/config.php.sample tests/config.local.php
# edit tests/config.local.php: your auth/portal URLs, an admin account,
# two committee accounts + their tokens (see committeetokens.json), and
# the admin token from storage/pd2026/admintoken.php
php tests/run.php
```

No PHPUnit — a deliberately tiny custom runner (`tests/lib/`), so it
doesn't need any PHP extensions beyond what the app itself already
requires. It covers: an admin account can reach `admin.php`; that same
account is correctly refused on `committee.php`; a committee account can
reach `committee.php` with its own token; that account is refused when
using someone else's token (the impersonation guard); and the fully
public pages (`register.php`, `dataprotection.php`) load with no session
at all. `tests/config.local.php` holds real account credentials for
whatever server you're testing — keep it out of version control the same
way you'd keep `secrets.php` out of it.

One caveat: this only works while the test accounts have no TOTP secret
set yet (`login()` doesn't send a 2FA code). If you complete 2FA setup on
an account you're using for testing, the suite will start failing at the
login step until someone adds TOTP code generation to
`tests/lib/HttpClient.php` — easiest fix is just using a dedicated test
account that never gets 2FA turned on.

## Known gaps — read before going live with real people

- **No security audit.** This has had an iterative hardening pass (CSRF,
  path traversal, weak RNG, timing-safe comparisons, rate limiting) during
  development, not a professional review. Treat it as a solid starting
  point, not a finished, supported product.
- **Privacy/GDPR notice (`portal/dataprotection.php`) needs real legal
  review** for your jurisdiction and institute before real applicants see
  it. What's there is drafted for MPA's own process and only partially
  genericized.
- **PHPMailer and webfonts are duplicated** between `auth/` and `portal/`
  (each folder's PHP `require`s are relative, not shared) — if you ever
  update one, update the other too.
- **No automated backup.** `storage/` holds everything — applicant data,
  accounts, uploads. Back it up the way you'd back up any other
  application data directory; nothing here does it for you.

## License / support

None provided — this is shared as-is, a working reference to adapt, not a
maintained product.
