diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..88974cf Binary files /dev/null and b/.DS_Store differ diff --git a/.RData b/.RData new file mode 100644 index 0000000..8cf8ef6 Binary files /dev/null and b/.RData differ diff --git a/.Rhistory b/.Rhistory new file mode 100644 index 0000000..b76d5c8 --- /dev/null +++ b/.Rhistory @@ -0,0 +1,3 @@ +install.packages('jsonlite') +q +q() diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..192b97e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +.DS_Store +node_modules +vendor +uploads/* +!uploads/**/.gitkeep +.env diff --git a/.env b/.env new file mode 100644 index 0000000..623cc8c --- /dev/null +++ b/.env @@ -0,0 +1,21 @@ +JUPYTER_EXTERNAL_URL=https://hub.niph.org.kh +JUPYTERHUB_PORT=443 +JUPYTERHUB_AUTH_STRATEGY=oauth +JUPYTERHUB_DUMMY_PASSWORD= +JUPYTERHUB_COOKIE_SECURE=true +JUPYTERHUB_HTTP_TIMEOUT=90 +JUPYTERHUB_START_TIMEOUT=90 +JUPYTERHUB_USER_PATH=hub/ +JUPYTERHUB_USERNAME_TEMPLATE=user_{person_id} +DSP_WORKSPACE_ROOT=/home/niph_dev/Documents/dsp/uploads/jupyter_workspace +DSP_OAUTH_CLIENT_ID=hub-client +DSP_OAUTH_CLIENT_SECRET=hub-client-secret-20251103 +DSP_OAUTH_REDIRECT_URIS=https://hub.niph.org.kh/hub/oauth_callback +DSP_OAUTH_AUTHORIZE_URL=https://dsp.niph.org.kh/oauth/authorize +DSP_OAUTH_TOKEN_URL=https://dsp.niph.org.kh/oauth/token +DSP_OAUTH_USERINFO_URL=https://dsp.niph.org.kh/oauth/userinfo +JUPYTERHUB_OAUTH_CALLBACK=https://hub.niph.org.kh/hub/oauth_callback +JUPYTERHUB_CULL_API_TOKEN=kCc1EzULTX2jG8jyvOTA0B6vrMk5SGmWjwDvNqlQ2wY +DSP_APP_ORIGINS="https://dsp.niph.org.kh https://hub.niph.org.kh http://localhost:8082 http://192.168.170.226:8082" +DSP_FRAME_ANCESTORS="https://dsp.niph.org.kh https://hub.niph.org.kh http://localhost:8082 http://192.168.170.226:8082" +APP_PORT=443 diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..c73412a --- /dev/null +++ b/.htaccess @@ -0,0 +1,8 @@ +RewriteEngine On +RewriteBase / + +# Serve existing PHP files when the extension is omitted +RewriteCond %{REQUEST_FILENAME} !-d +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME}.php -f +RewriteRule ^(.+)$ $1.php [L] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..12b37bf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# Dockerfile for DSP PHP application with R support +FROM php:8.2-apache + +# Install system dependencies and PHP extensions +RUN apt-get update && apt-get install -y \ + libpng-dev \ + libonig-dev \ + libzip-dev \ + zip \ + unzip \ + git \ + default-mysql-client \ + r-base \ + && docker-php-ext-install pdo_mysql \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install required R packages +RUN Rscript -e "install.packages('jsonlite', repos='https://cloud.r-project.org')" + +# Enable Apache modules commonly used by PHP apps +RUN a2enmod rewrite + +# Set working directory +WORKDIR /var/www/html + +# Copy project files into the container (can be overridden by bind mount in docker-compose) +COPY . /var/www/html + +# Ensure upload directories are writable by the web server +RUN chown -R www-data:www-data /var/www/html/uploads + +# Increase default PHP upload limits +COPY docker/custom.ini /usr/local/etc/php/conf.d/uploads.ini + +# Copy entrypoint script +COPY docker/app-entrypoint.sh /usr/local/bin/app-entrypoint.sh +RUN chmod +x /usr/local/bin/app-entrypoint.sh + +# Expose Apache port +EXPOSE 80 + +ENTRYPOINT ["app-entrypoint.sh"] diff --git a/README.md b/README.md index e69de29..5b71de2 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,204 @@ +# DSP Platform Docker Setup + +This repository now includes a Docker-based development environment that brings up: + +- **PHP + Apache** web server (with Rscript available for the automated analyses) +- **MySQL 8.0** database seeded with the `db/niph_dsps.sql` dump on first run +- **phpMyAdmin** for administering the database through the browser +- **JupyterHub (per-user R-enabled JupyterLab)** for isolated notebook environments + +## Prerequisites + +- Docker Desktop (or Docker Engine + Docker Compose plugin) +- ~2 GB of free disk space for the base images + +## Quick start + +```bash +# From the project root +docker-compose up --build +``` + +Once the stack is healthy you can reach the services at: + +| Service | URL | Notes | +|-----------------|------------------------------|-------| +| PHP application | http://localhost:8082 | Uses DB credentials from `docker-compose.yml` | +| phpMyAdmin | http://localhost:8081 | Login with `dsp_user` / `dsp_pass` (or MySQL root) | +| JupyterHub | https://localhost | OAuth handshake redirects to your private notebook (published on port 443) | +| MySQL | localhost:3307 (host access) | Database `niph_dsps`, user `dsp_user` / `dsp_pass` | + +The first `docker-compose up` will import `db/niph_dsps.sql` automatically. Subsequent runs keep the data volume (`mysql_data`). + +## Configuration + +Key environment variables are defined in `docker-compose.yml`. Adjust them if you need different credentials or ports. The PHP application now reads its database configuration from the following variables (with sensible defaults for non-Docker setups): + +- `DB_HOST` +- `DB_PORT` +- `DB_NAME` +- `DB_USER` +- `DB_PASS` + +`api/run_r_script.php` also honours `RSCRIPT_PATH` if you need to override the default location of the `Rscript` executable. + +When the portal is hosted on a different hostname (for example, an Ubuntu server on your LAN), set the following variables—either in your shell or a `.env` file consumed by Docker Compose—to keep the embedded JupyterHub session aligned with browser security rules: + +- `JUPYTER_EXTERNAL_URL` – full base URL that the PHP app should point at (e.g. `https://niphdev.local`) +- `JUPYTERHUB_PORT` – published port if you map JupyterHub to something other than `443` (legacy deployments can continue to set `JUPYTER_PORT`) +- `DSP_APP_ORIGINS` – space-separated list of origins allowed to call notebook APIs (CORS) +- `DSP_FRAME_ANCESTORS` – space-separated list of origins permitted to embed JupyterHub in an iframe + +### Platform roles at a glance + +The application enforces the following roles via `ist_tbl_users.isu_status` and the helper functions in `includes/auth.php`. Use this matrix to confirm which actions (upload, read, download, approve) each role can take before issuing credentials: + +| Role | Primary workspace | Upload / manage data sources | Approve access requests | Request / read / download datasets | Jupyter / R access | +|------|-------------------|------------------------------|-------------------------|------------------------------------|--------------------| +| **DAC Staff** | `admin/` area | ✅ Full oversight of every dataset, classification, and content entry. | ✅ Manage any permission, revoke and audit usage. | ✅ Can impersonate workflows when testing, but typically not used for research downloads. | ✅ Enable per-user via `isu_can_run_r`; also seeds OAuth credentials. | +| **Data Owner** | `data_owner/` | ✅ Create and maintain their own catalogue entries and metadata. | ✅ Approve, reject, or revoke requests for the data they own. | ✅ Access their own approved files plus anything they have requested from others. | ✅ Optional; grant by setting `isu_can_run_r = 1`. Only approved files sync into their notebook. | +| **Data Contributor** | `data_hybrid/` | ✅ Similar to owners, contributors can upload/publish datasets delegated to them. | ✅ Limited to the resources they registered or steward. | ✅ Can request access to other datasets and, once approved, read/download/analyze. | ✅ Optional per account; ideal for analysts who both publish and consume data. | +| **Data User** | `data_user/` | ❌ Browse-only catalogue view. | ❌ Cannot approve requests. | ✅ May request access, then read/download once a Data Owner or DAC Staff approves the request. | ✅ Optional; if enabled, only their approved files appear in Jupyter. | + +> **Tip:** updating a user’s role or R access flag happens under **Admin → Manage Users**. Toggle the “Allow R/Jupyter” switch to control whether uploads are synchronized into their personal notebook volume. + +To wire DSP into JupyterHub via OAuth, also provide: + +- `DSP_OAUTH_CLIENT_ID` / `DSP_OAUTH_CLIENT_SECRET` +- `DSP_OAUTH_AUTHORIZE_URL`, `DSP_OAUTH_TOKEN_URL`, `DSP_OAUTH_USERINFO_URL` +- `JUPYTERHUB_OAUTH_CALLBACK` +- `JUPYTERHUB_USER_PATH` and `JUPYTERHUB_USERNAME_TEMPLATE` if you need custom routing/usernames +- `JUPYTERHUB_CULL_API_TOKEN` (optional) – set to enable the idle culler service + +Seed or update the OAuth client after setting these env vars: + +```bash +docker-compose exec app php scripts/seed_jupyterhub_client.php +``` + +The JupyterHub deployment trusts requests and iframe parents from `localhost:8082`, `127.0.0.1:8082`, and `https://dsp.niph.org.kh` by default. To allow different origins (for example your own DSP deployment), set: + +- `DSP_APP_ORIGINS` – space-separated list of origins that should be accepted for CORS/websocket requests (e.g. `DSP_APP_ORIGINS="https://dsp.niph.org.kh"`). +- `DSP_FRAME_ANCESTORS` – space-separated list of origins allowed to embed the notebook in an iframe (e.g. `DSP_FRAME_ANCESTORS="https://dsp.niph.org.kh"`). + +JupyterHub is published on host port `443` (configurable via the `JUPYTERHUB_PORT` environment variable in `docker-compose.yml`), so a deployment reachable at `https://dsp.niph.org.kh` works out of the box. + +## Project directories shared with containers + +| Host directory | Container (app) | Container (Jupyter) | +|-----------------------|-------------------------|------------------------------------| +| `.` (project root) | `/var/www/html` | – | +| `r_scripts/` | `/var/www/html/r_scripts` | `/home/jovyan/work/r_scripts` | +| `uploads/jupyter_workspace` | `/var/www/html/uploads/jupyter_workspace` | `/home/jovyan/work` (per-user mount inside spawned notebook) | + +Uploads remain writable from the PHP container. If you run into permission warnings on macOS/Linux, +`chmod -R 777 uploads` (or a tighter group-based permission) on the host usually resolves it. The path is bind-mounted into the `dsp_app` container, so ensure permissions are adjusted on the host side. + +- Uploaded files are stored under `uploads/datasources/` with names like `datasource__.ext`. This keeps paths unique while preserving a readable hint of the original filename. The default PHP upload limit is set to `20M` (see `docker/custom.ini`). + +- The `logs/app.log` file (created via `config.php`) records upload activity—if you do not see `[DataSource]` entries after an upload, confirm the app container can reach MySQL (`docker exec dsp_app php -r 'require "config.php"; echo "connected";'`). + +## Architecture Overview + +```mermaid +graph LR + subgraph Client + U[Browser / API Consumer] + end + + subgraph Docker Stack + A[PHP + Apache
dsp_app] + B[(MySQL 8.0
dsp_db)] + C[phpMyAdmin
dsp_phpmyadmin] + D[Jupyter Notebook
dsp_jupyter] + V1[(uploads/datasources)] + V2[(r_scripts)] + end + + U -->|HTTPS/HTTP :8082| A + U -->|HTTPS/HTTP :8081| C + U -->|HTTPS :443| D + A <-->|SQL :3306| B + C -->|Admin SQL| B + A -.shared volume .-> V1 + A -.shared volume .-> V2 + D -.shared volume .-> V1 + D -.shared volume .-> V2 +``` + +*Traffic legend:* solid lines represent runtime traffic, dotted lines represent bind-mounted volumes that synchronize datasets and R scripts between containers. + +> Need the raw Mermaid for presentations? See `assets/diagrams/data_ecosystem.mmd`. + +## Data Model Snapshot + +```mermaid +erDiagram + IST_TBL_PEOPLE ||--o{ IST_TBL_USERS : "fkisp_id_of" + IST_TBL_PEOPLE ||--o{ DSPS_TBL_DATASOURCE : "fkisp_id_of" + DSPS_TBL_TYPEDATASOURCE ||--o{ DSPS_TBL_DATASOURCE : "fkdspstds_id" + DSPS_TBL_DSPSCATEGORY ||--o{ DSPS_TBL_DATASOURCE : "fkdspscate_id" + DSPS_TBL_DATASOURCE ||--o{ DSPS_TBL_DATASOURCE_PERMISSION : "fkdspsds_id" + IST_TBL_PEOPLE ||--o{ DSPS_TBL_DATASOURCE_PERMISSION : "fkisp_id_of (requester)" + DSPS_TBL_DATASOURCE ||--o{ DSPS_TBL_DATASOURCE_USED : "fkdspsdsused_id" + IST_TBL_PEOPLE ||--o{ DSPS_TBL_DATASOURCE_USED : "fkisp_id_of (consumer)" +``` + +The diagram highlights how every dataset anchors to a person record, while permissions and usage logs capture cross-person interactions for auditing. + +## Analytics Catalog + +Analytics scripts live in `r_scripts/` and are exposed through `api/run_r_script.php`. Each script receives two CLI arguments: the absolute path to a CSV prepared by PHP and a JSON string of runtime parameters. + +| Script | Purpose | Required Parameters | Optional Parameters | Output | +|--------|---------|---------------------|---------------------|--------| +| `data_summary.R` | Smoke-test script that confirms connectivity between PHP and R, echoing the received file path and parameters. | _None_ | Any JSON payload is echoed back in `params_received`. | JSON with `message`, `data_file`, and the raw parameter string. | +| `descriptive_stats.R` | Generates descriptive statistics for every numeric column (count, mean, median, SD, min, max, missing) and returns up to five preview rows. | _None_ (operates on all numeric columns). | `encoding` (default `UTF-8`), `guess_max` to control type inference. | JSON payload containing `numeric_columns` keyed by column name plus `sample_rows`. Missing values are encoded as `null`. | +| `category_frequency.R` | Builds a frequency distribution for a categorical column. Useful for validating controlled vocabularies or spotting dominant categories. | `column` – name of the column to profile. | `top_n` (default `10`), `encoding` (default `UTF-8`), `include_missing` (`false` by default). | JSON with the analyzed column, configuration echo, and `frequencies` (value/count rows) sorted by frequency. | + +### Adding another R script + +1. Drop the script into `r_scripts/` and ensure it prints JSON via `jsonlite::toJSON(...)`. +2. Append the filename and human-readable label to `$allowed_r_scripts` inside `api/run_r_script.php`. +3. Document the new script in the table above so stakeholders understand its expected parameters and output contract. + +## Useful commands + +```bash +# Stop and remove containers, keeping the database volume +docker-compose down + +# Stop containers and remove the database volume (fresh start) +docker-compose down -v + +# Tail logs from all services +docker-compose logs -f +``` + +## Running Tests + +PHPUnit is configured via Composer: + +```bash +# Install dependencies (first run) +composer install + +# Execute the test suite +composer test +``` + +If you prefer running inside the app container: + +```bash +docker-compose exec app composer install +docker-compose exec app composer test +``` + +## Troubleshooting + +- **MySQL already initialised**: remove the `mysql_data` named volume (`docker-compose down -v`) to force a clean import. +- **Rscript not found**: ensure the PHP container has R installed (`docker-compose build` again). Set `RSCRIPT_PATH` in `docker-compose.yml` if R lives elsewhere. +- **Port clashes**: adjust the published ports (`8082`, `8081`, `443`, `3307`) in `docker-compose.yml` to free ones on your machine. +- **Need the OAuth tables?**: run `docker-compose exec db mysql -u root -p niph_dsps < db/migrations/20241103_oauth_tables.sql` then insert your JupyterHub client credentials. + +Happy hacking! diff --git a/admin/app_log.php b/admin/app_log.php new file mode 100644 index 0000000..4ac1f7a --- /dev/null +++ b/admin/app_log.php @@ -0,0 +1,119 @@ + null, + 'modified' => null, +]; + +if ($canReadLog) { + $logMeta['size'] = filesize($logPath); + $logMeta['modified'] = filemtime($logPath); + + if (isset($_GET['download'])) { + header('Content-Type: text/plain'); + header('Content-Disposition: attachment; filename="app.log"'); + header('Content-Length: ' . $logMeta['size']); + readfile($logPath); + exit; + } + + try { + $logEntries = tail_file($logPath, $lineLimit); + } catch (RuntimeException $e) { + $canReadLog = false; + $logError = $e->getMessage(); + } +} else { + $logError = 'The application log file is missing or unreadable.'; +} + +function tail_file(string $path, int $lines): array +{ + $file = new SplFileObject($path, 'r'); + $file->seek(PHP_INT_MAX); + $lastLine = $file->key(); + $startLine = max($lastLine - $lines + 1, 0); + $file->seek($startLine); + + $buffer = []; + while (!$file->eof()) { + $buffer[] = rtrim($file->current(), "\r\n"); + $file->next(); + } + + return $buffer; +} + +$lastUpdated = $logMeta['modified'] ? date('Y-m-d H:i:s', $logMeta['modified']) . ' UTC' : 'Unknown'; +$logSizeHuman = $logMeta['size'] !== null ? number_format($logMeta['size'] / 1024, 1) . ' KB' : 'Unknown'; + +?> + + + + +
+ +
+
+
+

Application Log

+

Streaming the latest lines from logs/app.log.

+
+
+ Last updated: + Size: +
+
+ +
+
+
+ + +
+ + + Refresh + + + Download + + +
+
+ + +
+ Cannot read log file. +
+
+ +
+
+
+
+
+ +
+
+ + + diff --git a/admin/dashboard.php b/admin/dashboard.php new file mode 100644 index 0000000..3232dfd --- /dev/null +++ b/admin/dashboard.php @@ -0,0 +1,171 @@ +getTotalUsers(); +$totalAnnouncements = $announcement->getTotalAnnouncements(); +$totalDataSources = $dataSource->getTotalDataSources(); +$totalCategories = $classification->getTotalCategories(); +$totalDataTypes = $classification->getTotalDataTypes(); +$totalFeedback = $contactUs->getTotalFeedback(); +$totalFaqs = $faq->getTotalFaqs(); +$totalSlides = $slideManager->getTotalSlides(); +$pendingPermissions = $dataSource->getPendingPermissionRequestsCount(); // This correctly gets ALL pending for admin dashboard +?> + + + + + + + +
+ + + +
+ + + ' . htmlspecialchars($_SESSION['message']) . '
'; + unset($_SESSION['message']); + unset($_SESSION['message_type']); + } + ?> + +
+
+
+
+ +
Total Users
+

+
+
+
+
+
+
+ +
Total Announcements
+

+
+
+
+
+
+
+ +
Total Data Sources
+

+
+
+
+
+
+
+ +
Data Categories
+

+
+
+
+
+
+
+ +
Data Types
+

+
+
+
+
+
+
+ +
Pending Permissions
+

+
+
+
+
+
+
+ +
New Feedback
+

+
+
+
+
+
+
+ +
Total FAQs
+

+
+
+
+
+
+
+ +
Total Slides
+

+
+
+
+
+ +
+ + + + + + diff --git a/admin/manage_aboutus.php b/admin/manage_aboutus.php new file mode 100644 index 0000000..be30d02 --- /dev/null +++ b/admin/manage_aboutus.php @@ -0,0 +1,216 @@ +getUserDetails($_SESSION['user_id']); + $fkisp_id_of = $currentUserDetails['fkisp_id_of']; + + if ($action_type === 'delete') { + if ($about_id) { + try { + $aboutUs->deleteAboutUs($about_id); + set_message('About Us entry deleted successfully!', 'success'); + } catch (Exception $e) { + set_message('Error deleting About Us entry: ' . $e->getMessage(), 'danger'); + } + } + } else { + if (empty($title) || empty($description)) { + set_message('Title and description cannot be empty.', 'danger'); + } else { + try { + if ($action_type === 'add') { + $aboutUs->addAboutUs($title, $description, $_SESSION['user_id'], $fkisp_id_of); + set_message('About Us entry added successfully!', 'success'); + } elseif ($action_type === 'edit' && $about_id) { + $aboutUs->updateAboutUs($about_id, $title, $description, $_SESSION['user_id'], $fkisp_id_of); + set_message('About Us entry updated successfully!', 'success'); + } + } catch (Exception $e) { + set_message('Error: ' . $e->getMessage(), 'danger'); + } + } + } + header('Location: manage_aboutus.php'); + exit(); +} + +// Fetch About Us entries for display +$aboutUsEntries = $aboutUs->getAllAboutUs(); + +// Prepare data for editing if action is 'edit' +$editAboutUs = null; +if ($action === 'edit' && $id) { + $editAboutUs = $aboutUs->getAboutUsById($id); +} +?> + + + + + + +
+ + + + +
+ + + ' . htmlspecialchars($_SESSION['message']) . '
'; + unset($_SESSION['message']); + unset($_SESSION['message_type']); + } + ?> + +
+
+
About Us Entry
+
+
+
+ + + + + +
+ + +
+
+ + +
Format paragraphs, bullet lists, and links so the public About Us page is easy to read.
+
+ + + Cancel Edit + +
+
+
+ +
+
+
All About Us Entries
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDTitleDescriptionReg. DateMod. DateActions
100 ? '...' : ''); ?> + + + +
+ + + +
+
No About Us entries found.
+
+
+
+
+ + + + + + + diff --git a/admin/manage_announcements.php b/admin/manage_announcements.php new file mode 100644 index 0000000..655d4c6 --- /dev/null +++ b/admin/manage_announcements.php @@ -0,0 +1,260 @@ +deleteAnnouncement($announcement_id); + set_message('Announcement deleted successfully!', 'success'); + } catch (Exception $e) { + set_message('Error deleting announcement: ' . $e->getMessage(), 'danger'); + } + } + } else { + // Handle photo upload + $photoPath = $current_photo; // Default to current photo if not uploading new + if (isset($_FILES['photo']) && $_FILES['photo']['error'] === UPLOAD_ERR_OK) { + try { + $photoPath = $announcement->handlePhotoUpload($_FILES['photo']); + // If editing and a new photo is uploaded, delete the old one + if ($action_type === 'edit' && !empty($current_photo) && $current_photo !== $photoPath) { + unlink('../uploads/announcements/' . $current_photo); // Delete old file + } + } catch (Exception $e) { + set_message('Photo upload error: ' . $e->getMessage(), 'danger'); + header('Location: manage_announcements.php'); + exit(); + } + } + + if (empty($title) || empty($description)) { + set_message('Title and description cannot be empty.', 'danger'); + } else { + try { + if ($action_type === 'add') { + $announcement->addAnnouncement($title, $description, $photoPath, $status, $_SESSION['user_id']); + set_message('Announcement added successfully!', 'success'); + } elseif ($action_type === 'edit' && $announcement_id) { + $announcement->updateAnnouncement($announcement_id, $title, $description, $photoPath, $status, $_SESSION['user_id']); + set_message('Announcement updated successfully!', 'success'); + } + } catch (Exception $e) { + set_message('Error: ' . $e->getMessage(), 'danger'); + } + } + } + header('Location: manage_announcements.php'); + exit(); +} + +// Fetch announcements for display +$announcements = $announcement->getAllAnnouncements(); + +// Prepare data for editing if action is 'edit' +$editAnnouncement = null; +if ($action === 'edit' && $id) { + $editAnnouncement = $announcement->getAnnouncementById($id); +} +?> + + + + + + +
+ + + +
+ + + ' . htmlspecialchars($_SESSION['message']) . '
'; + unset($_SESSION['message']); + unset($_SESSION['message_type']); + } + ?> + +
+
+
Announcement
+
+
+
+ + + + + + +
+ + +
+
+ + +
Use the toolbar to format bullet lists, emphasize important actions, and link to additional resources.
+
+
+ + + +
+ Current Photo: Announcement Photo +
+ +
+
+ + +
+ + + Cancel Edit + +
+
+
+ +
+
+
All Announcements
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDTitleDescriptionPhotoStatusReg. DateMod. DateActions
100 ? '...' : ''); ?> + + Photo + + N/A + + + + + +
+ + + +
+
No announcements found.
+
+
+
+
+ + + + + + + + diff --git a/admin/manage_classifications.php b/admin/manage_classifications.php new file mode 100644 index 0000000..9739e7e --- /dev/null +++ b/admin/manage_classifications.php @@ -0,0 +1,269 @@ +addDataType($name_en, $name_kh, $_SESSION['user_id']); + set_message('Data Type added successfully!', 'success'); + } elseif ($action_type === 'edit' && $record_id) { + $classification->updateDataType($record_id, $name_en, $name_kh, $_SESSION['user_id']); + set_message('Data Type updated successfully!', 'success'); + } elseif ($action_type === 'delete' && $record_id) { + $classification->deleteDataType($record_id); + set_message('Data Type deleted successfully!', 'success'); + } + } elseif ($type === 'category') { + if ($action_type === 'add') { + $classification->addCategory($name_en, $details, $_SESSION['user_id']); + set_message('Category added successfully!', 'success'); + } elseif ($action_type === 'edit' && $record_id) { + $classification->updateCategory($record_id, $name_en, $details, $_SESSION['user_id']); + set_message('Category updated successfully!', 'success'); + } elseif ($action_type === 'delete' && $record_id) { + $classification->deleteCategory($record_id); + set_message('Category deleted successfully!', 'success'); + } + } + } catch (Exception $e) { + set_message('Error: ' . $e->getMessage(), 'danger'); + } + header('Location: manage_classifications.php'); + exit(); +} + +// Fetch data for display +$dataTypes = $classification->getAllDataTypes(); +$categories = $classification->getAllCategories(); + +// Prepare data for editing if action is 'edit' +$editDataType = null; +$editCategory = null; +if ($action === 'edit' && $id) { + if ($_GET['type'] === 'datatype') { + $editDataType = $classification->getDataTypeById($id); + } elseif ($_GET['type'] === 'category') { + $editCategory = $classification->getCategoryById($id); + } +} +?> + + + + + + +
+ + + + +
+ + + ' . htmlspecialchars($_SESSION['message']) . '
'; + unset($_SESSION['message']); + unset($_SESSION['message_type']); + } + ?> + + +
+
+
Manage Data Types
+
+
+
+ + + + + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
IDEnglish NameKhmer NameActions
+ + + +
+ + + + +
+
No data types found.
+
+
+
+ + +
+
+
Manage Categories
+
+
+
+ + + + + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
IDEnglish TitleDetailsActions
+ + + +
+ + + + +
+
No categories found.
+
+
+
+
+ + + + + + diff --git a/admin/manage_contactus.php b/admin/manage_contactus.php new file mode 100644 index 0000000..4ae8dce --- /dev/null +++ b/admin/manage_contactus.php @@ -0,0 +1,213 @@ +respondToFeedback($feedback_id, $respond_text, $status, $_SESSION['user_id']); + set_message('Feedback responded to successfully!', 'success'); + } catch (Exception $e) { + set_message('Error responding to feedback: ' . $e->getMessage(), 'danger'); + } + } + } elseif ($action_type === 'delete') { + if ($feedback_id) { + try { + $contactUs->deleteFeedback($feedback_id); + set_message('Feedback deleted successfully!', 'success'); + } catch (Exception $e) { + set_message('Error deleting feedback: ' . $e->getMessage(), 'danger'); + } + } + } + header('Location: manage_contactus.php'); + exit(); +} + +// Fetch feedback entries for display +$feedbackEntries = $contactUs->getAllFeedback(); + +// Prepare data for responding if action is 'respond' +$respondFeedback = null; +if ($action === 'respond' && $id) { + $respondFeedback = $contactUs->getFeedbackById($id); +} +?> + + + + + + +
+ + + + + +
+ + + ' . htmlspecialchars($_SESSION['message']) . '
'; + unset($_SESSION['message']); + unset($_SESSION['message_type']); + } + ?> + + +
+
+
Respond to Feedback #
+
+
+
+ From: () +
+
+ Submitted On: +
+
+ Message: +

+
+ +
+ Previous Response: +

+
+ + +
+ + + +
+ + +
+
+ + +
+ + Cancel +
+
+
+ + +
+
+
All Feedback Messages
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDNameEmailMessageStatusSubmitted OnActions
100 ? '...' : ''); ?> + + Respond + +
+ + + +
+
No feedback messages found.
+
+
+
+
+ + + + + + diff --git a/admin/manage_datasources.php b/admin/manage_datasources.php new file mode 100644 index 0000000..c8fa833 --- /dev/null +++ b/admin/manage_datasources.php @@ -0,0 +1,195 @@ + 0) { + $newStatus = trim($_POST['new_status'] ?? ''); + try { + $dataSourceManager->updateDataSourceStatus($datasourceId, $newStatus, (int) $_SESSION['user_id']); + set_message('Data source status updated successfully.', 'success'); + } catch (Exception $e) { + set_message('Failed to update status: ' . $e->getMessage(), 'danger'); + } + } elseif ($action === 'delete' && $datasourceId > 0) { + try { + if ($dataSourceManager->deleteDataSource($datasourceId)) { + set_message('Data source deleted.', 'success'); + } else { + set_message('Unable to delete data source.', 'danger'); + } + } catch (Exception $e) { + set_message('Deletion failed: ' . $e->getMessage(), 'danger'); + } + } + + $redirectUrl = 'manage_datasources.php'; + $params = []; + if ($search_query !== '') { + $params['search'] = urlencode($search_query); + } + if ($status_filter !== '') { + $params['status_filter'] = urlencode($status_filter); + } + if (!empty($params)) { + $redirectUrl .= '?' . http_build_query($params); + } + header('Location: ' . $redirectUrl); + exit(); +} + +$dataSources = $dataSourceManager->getAllDataSourcesDetailed( + $search_query !== '' ? $search_query : null, + $status_filter !== '' ? $status_filter : null +); + +$statuses = ['Active', 'Inactive', 'Pending Review', 'Published']; +$uploadsWebPath = '../uploads/datasources/'; +?> + + + + +
+ +
+ + + ' + . htmlspecialchars($_SESSION['message']) . + '
'; + unset($_SESSION['message'], $_SESSION['message_type']); + } + ?> + +
+
+
All Data Sources
+
+
+
+
+ + +
+
+ + +
+
+ + Reset +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDTitleOwnerTypeCategoryStatusFilesRegisteredActions
+
+ + + + +
+
+ 'Primary', + 'dspsds_filename1' => 'Doc 1', + 'dspsds_filename2' => 'Doc 2', + 'dspsds_filename3' => 'Doc 3', + ]; + $links = []; + foreach ($fileColumns as $column => $label) { + $fileName = $ds[$column] ?? ''; + if ($fileName === '') { + continue; + } + $isUrl = preg_match('/^https?:\\/\\//i', $fileName) === 1; + $target = $isUrl ? $fileName : $uploadsWebPath . rawurlencode($fileName); + $links[] = ''; + } + echo !empty($links) ? implode('', $links) : ''; + ?> + +
+ + + +
+
No data sources found.
+
+
+
+
+ + + + diff --git a/admin/manage_faq.php b/admin/manage_faq.php new file mode 100644 index 0000000..69951af --- /dev/null +++ b/admin/manage_faq.php @@ -0,0 +1,218 @@ +getUserDetails($_SESSION['user_id']); + $fkisp_id_of = $currentUserDetails['fkisp_id_of']; + + if ($action_type === 'delete') { + if ($faq_id) { + try { + $faq->deleteFaq($faq_id); + set_message('FAQ entry deleted successfully!', 'success'); + } catch (Exception $e) { + set_message('Error deleting FAQ entry: ' . $e->getMessage(), 'danger'); + } + } + } else { + if (empty($title) || empty($description)) { + set_message('Question and Answer cannot be empty.', 'danger'); + } else { + try { + if ($action_type === 'add') { + $faq->addFaq($title, $description, $_SESSION['user_id'], $fkisp_id_of); + set_message('FAQ entry added successfully!', 'success'); + } elseif ($action_type === 'edit' && $faq_id) { + $faq->updateFaq($faq_id, $title, $description, $_SESSION['user_id'], $fkisp_id_of); + set_message('FAQ entry updated successfully!', 'success'); + } + } catch (Exception $e) { + set_message('Error: ' . $e->getMessage(), 'danger'); + } + } + } + header('Location: manage_faq.php'); + exit(); +} + +// Fetch FAQ entries for display +$faqEntries = $faq->getAllFaqs(); + +// Prepare data for editing if action is 'edit' +$editFaq = null; +if ($action === 'edit' && $id) { + $editFaq = $faq->getFaqById($id); +} +?> + + + + + + + +
+ + + + +
+ + + ' . htmlspecialchars($_SESSION['message']) . '
'; + unset($_SESSION['message']); + unset($_SESSION['message_type']); + } + ?> + +
+
+
FAQ Entry
+
+
+
+ + + + + +
+ + +
+
+ + +
Rich formatting appears on the public FAQ page—emphasise key steps and link to related resources.
+
+ + + Cancel Edit + +
+
+
+ +
+
+
All FAQ Entries
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDQuestionAnswerReg. DateMod. DateActions
100 ? '...' : ''); ?> + + + +
+ + + +
+
No FAQ entries found.
+
+
+
+
+ + + + + + + + diff --git a/admin/manage_permissions_admin.php b/admin/manage_permissions_admin.php new file mode 100644 index 0000000..2a3b361 --- /dev/null +++ b/admin/manage_permissions_admin.php @@ -0,0 +1,310 @@ +getPermissionRequestById($permission_id); + + if (!$permission_details) { + set_message("Permission request not found or invalid.", "danger"); + header('Location: manage_permissions_admin.php'); + exit(); + } + + // Optional: You could add a check here to ensure the data source itself is still active + // $dataSource = $dataSourceManager->getDataSourceById($permission_details['fkdspsds_id']); + // if (!$dataSource) { /* handle error */ } + + if (!in_array($new_status, ['Approved', 'Pending', 'Rejected', 'Revoked'])) { + set_message('Invalid permission status selected.', 'danger'); + } else { + // The reg_by for permission updates is the user who is logged in (DAC Staff) + $dataSourceManager->updatePermissionStatus( + (int) $permission_id, + $new_status, + (int) $_SESSION['user_id'], + $notes + ); + set_message('Permission status updated successfully!', 'success'); + } + } catch (Exception $e) { + set_message('Error updating permission status: ' . $e->getMessage(), 'danger'); + } + } + // Redirect to self, preserving search/filter parameters if they exist + $redirect_url = 'manage_permissions_admin.php'; + $query_params = []; + if (!empty($search_query)) { + $query_params['search'] = urlencode($search_query); + } + if (!empty($filter_status)) { + $query_params['status_filter'] = urlencode($filter_status); + } + if (!empty($query_params)) { + $redirect_url .= '?' . http_build_query($query_params); + } + header('Location: ' . $redirect_url); + exit(); +} + +// Fetch all permission requests based on search and filter parameters +$allPermissions = $dataSourceManager->getAllPermissionRequests($filter_status, $search_query); + +?> + + + + + + +
+ + + + +
+ + + ' . htmlspecialchars($_SESSION['message']) . '
'; + unset($_SESSION['message']); + unset($_SESSION['message_type']); + } + ?> + +
+
+
All Data Access Requests
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+ + Reset +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Req. IDData SourceRequesterData OwnerPermission TypeRequest NotesProofStatusRequested OnActions
—'; ?> + + + + View + + + N/A + + + + + + + +
No permission requests found.
+
+
+
+
+ + + + + + + + + + + diff --git a/admin/manage_slides.php b/admin/manage_slides.php new file mode 100644 index 0000000..63571b0 --- /dev/null +++ b/admin/manage_slides.php @@ -0,0 +1,258 @@ +getUserDetails($_SESSION['user_id']); +$fkisp_id_of = $currentUserDetails['fkisp_id_of']; + +// Handle form submissions +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action_type = $_POST['action_type'] ?? ''; // 'add' or 'edit' or 'delete' + $title_en = trim($_POST['title_en'] ?? ''); + $description = trim($_POST['description'] ?? ''); + $slide_id = $_POST['slide_id'] ?? null; + $current_photo = $_POST['current_photo'] ?? ''; // For editing, keep track of existing photo + + if ($action_type === 'delete') { + if ($slide_id) { + try { + $slideManager->deleteSlide($slide_id); + set_message('Slide deleted successfully!', 'success'); + } catch (Exception $e) { + set_message('Error deleting slide: ' . $e->getMessage(), 'danger'); + } + } + } else { + // Handle photo upload + $photoPath = $current_photo; // Default to current photo if not uploading new + if (isset($_FILES['photo']) && $_FILES['photo']['error'] === UPLOAD_ERR_OK) { + try { + $photoPath = $slideManager->handlePhotoUpload($_FILES['photo']); + // If editing and a new photo is uploaded, delete the old one + if ($action_type === 'edit' && !empty($current_photo) && $current_photo !== $photoPath) { + // Ensure the old photo path is not empty and different from the new one + if (!empty($current_photo) && file_exists('../uploads/slides/' . $current_photo)) { + unlink('../uploads/slides/' . $current_photo); // Delete old file + } + } + } catch (Exception $e) { + set_message('Photo upload error: ' . $e->getMessage(), 'danger'); + header('Location: manage_slides.php'); + exit(); + } + } elseif ($action_type === 'add' && (!isset($_FILES['photo']) || $_FILES['photo']['error'] !== UPLOAD_ERR_OK)) { + // For adding, a photo is required. + set_message('Please upload a photo for the slide.', 'danger'); + header('Location: manage_slides.php'); + exit(); + } + + + if (empty($title_en) || empty($description)) { + set_message('Title and description cannot be empty.', 'danger'); + } else { + try { + if ($action_type === 'add') { + $slideManager->addSlide($title_en, $description, $photoPath, $_SESSION['user_id'], $fkisp_id_of); + set_message('Slide added successfully!', 'success'); + } elseif ($action_type === 'edit' && $slide_id) { + $slideManager->updateSlide($slide_id, $title_en, $description, $photoPath, $_SESSION['user_id'], $fkisp_id_of); + set_message('Slide updated successfully!', 'success'); + } + } catch (Exception $e) { + set_message('Error: ' . $e->getMessage(), 'danger'); + } + } + } + header('Location: manage_slides.php'); + exit(); +} + +// Fetch slides for display +$slides = $slideManager->getAllSlides(); + +// Prepare data for editing if action is 'edit' +$editSlide = null; +if ($action === 'edit' && $id) { + $editSlide = $slideManager->getSlideById($id); +} +?> + + + + + + +
+ + + +
+ + + ' . htmlspecialchars($_SESSION['message']) . '
'; + unset($_SESSION['message']); + unset($_SESSION['message_type']); + } + ?> + +
+
+
Slide
+
+
+
+ + + + + + +
+ + +
+
+ + +
Formatted text appears on the public carousel, so emphasise key phrases and provide concise summaries.
+
+
+ + > + +
+ Current Photo: Slide Photo +
+ +
+ + + Cancel Edit + +
+
+
+ +
+
+
All Slides
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDTitleDescriptionPhotoReg. DateActions
100 ? '...' : ''); ?> + + Slide Photo + + N/A + + + + + +
+ + + +
+
No slides found.
+
+
+
+
+ + + + + + + + diff --git a/admin/manage_users.php b/admin/manage_users.php new file mode 100644 index 0000000..43c643b --- /dev/null +++ b/admin/manage_users.php @@ -0,0 +1,782 @@ +updateUserStatus($user_id, $new_status, $admin_user_id); + + if ($has_valid_r_flag) { + $userManager->updateUserRJupyterAccess($user_id, $requested_r_access === '1', $admin_user_id); + + if ((int)$user_id === (int)$_SESSION['user_id']) { + $_SESSION['can_run_r'] = ($requested_r_access === '1'); + } + } + + $message = 'User status updated successfully!'; + if ($has_valid_r_flag) { + $message = 'User status and R/Jupyter access updated successfully!'; + } + set_message($message, 'success'); + } catch (Exception $e) { + set_message('Error updating user status: ' . $e->getMessage(), 'danger'); + } + } + } elseif ($action_type === 'reset_password' && $user_id) { + $new_password = $_POST['new_password'] ?? ''; + $confirm_password = $_POST['confirm_password'] ?? ''; + + if (empty($new_password) || empty($confirm_password)) { + set_message('Please provide and confirm the new password.', 'danger'); + } elseif ($new_password !== $confirm_password) { + set_message('Passwords do not match. Please try again.', 'danger'); + } elseif (strlen($new_password) < 8) { + set_message('Password must be at least 8 characters long.', 'danger'); + } else { + try { + $admin_user_id = (int) $_SESSION['user_id']; + $userManager->changePassword((int)$user_id, $new_password, $admin_user_id); + set_message('Password reset successfully.', 'success'); + } catch (Exception $e) { + set_message('Error resetting password: ' . $e->getMessage(), 'danger'); + } + } + } elseif ($action_type === 'add_user') { + // --- Handle Add New User Submission --- + $id_card = trim($_POST['id_card'] ?? ''); + $first_name_en = trim($_POST['first_name_en'] ?? ''); + $last_name_en = trim($_POST['last_name_en'] ?? ''); + $sex = trim($_POST['sex'] ?? ''); + $dob = trim($_POST['dob'] ?? ''); + $phone_number = trim($_POST['phone_number'] ?? ''); + $email = trim($_POST['email'] ?? ''); + $username = trim($_POST['username'] ?? ''); + $password = $_POST['password'] ?? ''; + $confirm_password = $_POST['confirm_password'] ?? ''; + $user_role_new = trim($_POST['user_role_new'] ?? 'Data User'); // Role for new user + + // Server-side validation for new user + if (empty($first_name_en) || empty($last_name_en) || empty($sex) || empty($dob) || empty($username) || empty($password) || empty($confirm_password)) { + set_message("All required fields for new user must be filled.", "danger"); + } elseif ($password !== $confirm_password) { + set_message("Passwords do not match for new user.", "danger"); + } elseif (!empty($email) && !filter_var($email, FILTER_VALIDATE_EMAIL)) { + set_message("Invalid email format for new user.", "danger"); + } else { + // Prepare data for User class + $person_data = [ + 'id_card' => $id_card, + 'first_name_en' => $first_name_en, + 'last_name_en' => $last_name_en, + 'sex' => $sex, + 'dob' => $dob, + 'pob' => null, // Add if you collect this + 'nationality' => 'Cambodian', // Default or collect + 'marital_status' => 'Single', // Default or collect + 'phone_number' => $phone_number, + 'email' => $email, + 'telegram' => null, // Add if you collect this + 'note' => null // Add if you collect this + ]; + + $user_data = [ + 'username' => $username, + 'password' => $password, + 'status' => 'Data User', // Default status for new registrations + 'can_run_r' => !empty($_POST['user_can_run_r']) + ]; + + try { + if ($userManager->registerUser($person_data, $user_data)) { + set_message("New user '" . htmlspecialchars($username) . "' registered successfully!", "success"); + } else { + // This else might be redundant if registerUser always throws on failure + set_message("Failed to register new user due to an unknown error.", "danger"); + } + } catch (Exception $e) { + set_message('Error registering new user: ' . $e->getMessage(), 'danger'); + } + } + } + + // Redirect to self, preserving search/filter parameters if they exist + $redirect_url = 'manage_users.php'; + $query_params = []; + if (!empty($search_query)) { + $query_params['search'] = urlencode($search_query); + } + if (!empty($filter_status)) { + $query_params['status_filter'] = urlencode($filter_status); + } + if (!empty($query_params)) { + $redirect_url .= '?' . http_build_query($query_params); + } + header('Location: ' . $redirect_url); + exit(); +} + +// Fetch users based on search and filter parameters +// We will modify getAllUsers in classes/User.php to accept these parameters +$users = $userManager->getAllUsers($search_query, $filter_status); + +$totalUsers = count($users); +$activeUsers = 0; +$inactiveUsers = 0; +$dacStaffCount = 0; +$ownerCount = 0; +$contributorCount = 0; +$rAccessCount = 0; + +foreach ($users as $user) { + $status = $user['isu_status'] ?? ''; + $isActive = $status !== 'Inactive'; + if ($isActive) { + $activeUsers++; + } else { + $inactiveUsers++; + } + + if ($status === 'DAC Staff') { + $dacStaffCount++; + } elseif ($status === 'Data Owner') { + $ownerCount++; + } elseif ($status === 'Data Contributor') { + $contributorCount++; + } + + if (!empty($user['isu_can_run_r'])) { + $rAccessCount++; + } +} + +$summaryMetrics = [ + [ + 'label' => 'Total Users', + 'value' => $totalUsers, + 'icon' => 'fa-users', + 'class' => 'bg-primary-subtle text-primary', + 'icon_class' => 'text-primary' + ], + [ + 'label' => 'Active Accounts', + 'value' => $activeUsers, + 'icon' => 'fa-user-check', + 'class' => 'bg-success-subtle text-success', + 'icon_class' => 'text-success' + ], + [ + 'label' => 'With R/Jupyter', + 'value' => $rAccessCount, + 'icon' => 'fa-flask', + 'class' => 'bg-info-subtle text-info', + 'icon_class' => 'text-info' + ], + [ + 'label' => 'Inactive', + 'value' => $inactiveUsers, + 'icon' => 'fa-user-slash', + 'class' => 'bg-warning-subtle text-warning', + 'icon_class' => 'text-warning' + ], +]; + +?> + + + + + + + +
+ + + + +
+ + + ' . htmlspecialchars($_SESSION['message']) . '
'; + unset($_SESSION['message']); + unset($_SESSION['message_type']); + } + ?> + +
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ +
+ +
+ DAC Staff: + Data Owners: + Contributors: + With R/Jupyter: +
+ +
+
+
All Registered Users
+
+ + +
+
+
+ +
+
+
+
+ +
+ + + + +
+
+
+ +
+ + + + +
+
+
+
+ + + Reset + +
+
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No.UsernameFull NameEmailPhoneCurrent RoleR/JupyterActions
+ + + + + + + Enabled + + Disabled + + +
+ + +
+
No users match the current filters.
+
+
+
+
+ + + + + + + + + + + + + + + + + diff --git a/admin/r_in_jupyter.php b/admin/r_in_jupyter.php new file mode 100644 index 0000000..73ec8df --- /dev/null +++ b/admin/r_in_jupyter.php @@ -0,0 +1,162 @@ + [], 'missing' => [], 'workspace_dir' => null]; +$workspaceRelativeDir = null; +$workspaceError = null; + +if ($hasRJupyterAccess && isset($_SESSION['person_id'])) { + $dataSourceManager = new DataSource($pdo); + try { + $workspaceSync = $dataSourceManager->prepareJupyterWorkspace( + (int) $_SESSION['person_id'], + dirname(__DIR__) . '/uploads/jupyter_workspace' + ); + $workspaceRelativeDir = 'datasources/user_' . (int) $_SESSION['person_id']; + } catch (Exception $e) { + $workspaceError = $e->getMessage(); + } +} + +$jupyterBaseUrl = dsp_jupyter_base_url(); +$jupyterToken = dsp_jupyter_token(); +$jupyterIframeUrl = dsp_jupyter_iframe_url( + $jupyterBaseUrl, + $jupyterToken, + isset($_SESSION['person_id']) ? (int) $_SESSION['person_id'] : null +); +?> + + + + +
+ + +
+ + +
+
+
Full JupyterLab Access
+ + + Enabled + + + + Disabled + + +
+
+ + +
+ Workspace error: +
+ +

+ Approved data sources have been synced to + + inside the Jupyter environment. Only files you are approved to use are available. +

+ +
+ + + + + + + + + + + + $syncedItem): ?> + + + + + + + + + +
#Data SourceData TypeCategoryFilename
+
+ +
+ No approved data sources were found for your account. Use Manage Users to approve access. +
+ + +
+ Some datasets could not be synced: +
    + +
  • + +
+
+ + +

+ Use the embedded Jupyter workspace to manage R notebooks, explore uploaded datasets, and + collaborate with Data Owners. This view runs with your admin permissions. +

+
+ +
+

+ Prefer the full window? Open Jupyter in a new tab. +

+ +
+ +
+ R in JupyterHub is currently disabled for your account.
+ Visit Manage Users to enable R/Jupyter access for yourself or ask another DAC Staff member to toggle the permission. +
+
+

+ Once access is enabled, refresh this page to launch the JupyterLab workspace. +

+ +
+
+
+ Need the current Jupyter configuration? Visit + Install & Configuration + for defaults, overrides, and runtime details. +
+
+
+ + + diff --git a/api/run_r_script.php b/api/run_r_script.php new file mode 100644 index 0000000..304e636 --- /dev/null +++ b/api/run_r_script.php @@ -0,0 +1,203 @@ + 'error', 'message' => 'Unauthorized. Please log in to run this script.']); + exit(); +} + +if (!has_r_access()) { + http_response_code(403); + echo json_encode(['status' => 'error', 'message' => 'You do not have permission to run R/Jupyter scripts.']); + exit(); +} + +if (empty($person_id)) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Missing user context for R execution.']); + exit(); +} + +$dataSourceManager = new DataSource($pdo); + +$data = json_decode(file_get_contents('php://input'), true); + +if ($data === null) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Invalid JSON input.']); + exit(); +} + +$script_name = $data['script_name'] ?? null; +$data_source_id = isset($data['data_source_id']) ? (int)$data['data_source_id'] : null; +$parameters = $data['parameters'] ?? []; + +$r_script_path_dir = __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'r_scripts' . DIRECTORY_SEPARATOR; + +$allowed_r_scripts = [ + 'data_summary.R' => 'Basic Data Summary', + 'descriptive_stats.R' => 'Numeric column descriptive statistics', + 'category_frequency.R' => 'Categorical frequency distribution', +]; + +if (!isset($allowed_r_scripts[$script_name])) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Invalid R script selected.']); + exit(); +} + +if (empty($data_source_id)) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Data source ID is required.']); + exit(); +} + +try { + $data_source_details = $dataSourceManager->getDataSourceById($data_source_id); +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => 'Failed to lookup data source.', 'debug' => $e->getMessage()]); + exit(); +} + +if (!$data_source_details) { + http_response_code(404); + echo json_encode(['status' => 'error', 'message' => 'Data source not found.']); + exit(); +} + +$data_source_owner_person_id = $data_source_details['fkisp_id_of'] ?? null; +$has_access_to_data = ($data_source_owner_person_id && (int)$data_source_owner_person_id === (int)$person_id); + +if (!$has_access_to_data) { + try { + $has_access_to_data = $dataSourceManager->hasPermission((int)$person_id, $data_source_id, 'Analyze'); + } catch (Exception $e) { + error_log('Analyze permission check failed: ' . $e->getMessage()); + $has_access_to_data = false; + } +} + +if (!$has_access_to_data) { + http_response_code(403); + echo json_encode(['status' => 'error', 'message' => 'You are not allowed to analyze this data source.']); + exit(); +} + +$source_filename = $data_source_details['dspsds_filename'] ?? ''; +if (empty($source_filename)) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'No associated data file found for this data source.']); + exit(); +} + +$upload_dir = $dataSourceManager->getUploadDir(); +$upload_dir_real = realpath($upload_dir) ?: $upload_dir; +$full_data_source_path = realpath($upload_dir . $source_filename); + +if ($full_data_source_path === false || strpos($full_data_source_path, $upload_dir_real) !== 0 || !is_file($full_data_source_path)) { + http_response_code(404); + echo json_encode(['status' => 'error', 'message' => 'Data source file could not be located.']); + exit(); +} + +// --- Create a temporary file for the R script to use --- +// This is a security best practice to prevent R from accessing arbitrary files. +$temp_data_file = tempnam(sys_get_temp_dir(), 'rdata_') . '.csv'; +copy($full_data_source_path, $temp_data_file); + +// Log the usage of the data source +error_log("Logging usage for data source: $data_source_id by user: $user_id for action: Ran Analysis"); + +// --- Path and Permission Checks --- +$r_executable_path = getenv('RSCRIPT_PATH') ?: '/usr/bin/Rscript'; +$r_script_full_path = $r_script_path_dir . $script_name; + +if (!file_exists($r_executable_path) || !is_executable($r_executable_path)) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => "Rscript executable not found or not executable at: {$r_executable_path}"]); + exit(); +} + +if (!file_exists($r_script_full_path) || !is_readable($r_script_full_path)) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => "R script not found or not readable at: {$r_script_full_path}"]); + exit(); +} + +// Build the command, specifying the R executable directly. +$params_json_str = json_encode($parameters, JSON_UNESCAPED_SLASHES); +if ($params_json_str === false) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => 'Failed to encode parameters to JSON.']); + exit(); +} + +$command = escapeshellcmd($r_executable_path) . " " . escapeshellarg($r_script_full_path) . " " . escapeshellarg($temp_data_file) . " " . escapeshellarg($params_json_str); + +// --- Use proc_open for better error capture --- +$descriptorspec = array( + 0 => array("pipe", "r"), // stdin + 1 => array("pipe", "w"), // stdout + 2 => array("pipe", "w") // stderr +); + +$process = proc_open($command, $descriptorspec, $pipes); + +if (!is_resource($process)) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => 'Failed to open process to R.']); + exit(); +} + +$stdout = stream_get_contents($pipes[1]); +fclose($pipes[1]); + +$stderr = stream_get_contents($pipes[2]); +fclose($pipes[2]); + +$return_code = proc_close($process); + +// Clean up the temporary data file +if (file_exists($temp_data_file)) { + unlink($temp_data_file); +} + +if ($return_code !== 0) { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'message' => "R script execution failed. Exit code: {$return_code}", + 'r_stdout' => $stdout, + 'r_stderr' => $stderr + ]); + exit(); +} + +$r_results = json_decode($stdout, true); + +if (json_last_error() !== JSON_ERROR_NONE) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => 'Failed to decode JSON from R script output.', 'r_stdout' => $stdout, 'r_stderr' => $stderr]); + exit(); +} + +try { + $dataSourceManager->logDataSourceUsage($data_source_id, (int)$person_id, 'Ran Analysis via API', (int)$user_id); +} catch (Exception $e) { + error_log('Failed to log R analysis usage: ' . $e->getMessage()); +} + +echo json_encode(['status' => 'success', 'results' => $r_results]); diff --git a/assets/.DS_Store b/assets/.DS_Store new file mode 100644 index 0000000..846e05d Binary files /dev/null and b/assets/.DS_Store differ diff --git a/assets/diagrams/data_ecosystem.mmd b/assets/diagrams/data_ecosystem.mmd new file mode 100644 index 0000000..1b65357 --- /dev/null +++ b/assets/diagrams/data_ecosystem.mmd @@ -0,0 +1,54 @@ +%% Data Ecosystem Diagram for NIPH DSP +%% Render with Mermaid (https://mermaid.js.org/) + +flowchart LR + subgraph Clients + U1[Public/Authenticated Users] + U2[DAC Staff] + end + + subgraph WebTier[PHP + Apache (dsp_app)] + UI[Portal UI
Dashboards & Workflows] + API[PHP APIs
Uploads & R runners] + end + + subgraph Database[(MySQL 8.0
dsp_db)] + DS[(dsps_tbl_datasource)] + PERM[(dsps_tbl_datasource_permission)] + PEOPLE[(ist_tbl_people & ist_tbl_users)] + USAGE[(dsps_tbl_datasource_used)] + end + + subgraph Storage[/Shared Volumes/] + FILES[(uploads/datasources)] + RSCRIPTS[(r_scripts)] + JWS[(uploads/jupyter_workspace)] + end + + subgraph Tooling + PMA[phpMyAdmin
dsp_phpmyadmin] + JUP[Jupyter (R kernel)
dsp_jupyter] + end + + U1 -->|HTTPS :8082| UI + U2 -->|HTTPS :8082| UI + U2 -->|Admin Ops| PMA + + UI --> API + API -->|SQL queries| Database + PMA -->|SQL admin| Database + + API -->|File uploads/downloads| FILES + API -->|Sync approved files| JWS + API -->|Whitelisted scripts| RSCRIPTS + + JUP -->|Mounts| FILES + JUP -->|Mounts| RSCRIPTS + JUP -->|Per-user workspace| JWS + JUP -->|Notebook insights| U1 + + Database -->|Metadata feeds| UI + Database -->|Permission checks| API + Database -->|Audit logs| DAC[DAC Reports] + + style DAC fill:#f7f9fb,stroke:#93a1c3,stroke-width:1px diff --git a/assets/images/niph_dsp_data_ecosystem.png b/assets/images/niph_dsp_data_ecosystem.png new file mode 100644 index 0000000..d1e9b86 Binary files /dev/null and b/assets/images/niph_dsp_data_ecosystem.png differ diff --git a/assets/images/niphlogo.ico b/assets/images/niphlogo.ico new file mode 100644 index 0000000..eddeea0 Binary files /dev/null and b/assets/images/niphlogo.ico differ diff --git a/assets/images/niphlogo.png b/assets/images/niphlogo.png new file mode 100644 index 0000000..4e06dfe Binary files /dev/null and b/assets/images/niphlogo.png differ diff --git a/browse_datasources.php b/browse_datasources.php new file mode 100644 index 0000000..679ea8e --- /dev/null +++ b/browse_datasources.php @@ -0,0 +1,247 @@ +getFilteredDataSources($category_id, $search_query); +} catch (Exception $e) { + // Log the error for debugging. + error_log("Error fetching data sources: " . $e->getMessage()); +} + +// Fetch all categories for the filter dropdown +$all_categories = []; +try { + $all_categories = $classification_manager->getAllCategories(); +} catch (Exception $e) { + error_log("Error fetching categories: " . $e->getMessage()); +} + +// These variables should be defined in your main index.php +// For this file to work correctly, you need to ensure $is_logged_in, $person_id, +// and the $permissionManager object are created and available. +$is_logged_in = false; // Placeholder, replace with actual logic +$person_id = null; // Placeholder, replace with actual logic + +$uploadsWebPath = 'uploads/datasources/'; +?> + +
+

Browse Data Sources

+ + +
+
+
+ + +
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+ " . htmlspecialchars($search_query) . "'"; + } + if (!empty($category_id)) { + // Get category details for the message + $category_details = null; + foreach ($all_categories as $cat) { + if (($cat['pkdspscate_id'] ?? '') == $category_id) { + $category_details = $cat; + break; + } + } + if ($category_details) { + $category_title = $category_details['dspscate_title_en'] ?? 'Unknown Category'; + $filters[] = "Category: '" . htmlspecialchars($category_title) . "'"; + } + } + echo $message . implode(" and ", $filters); + ?> +
+ + + +
+ +
+
+ + +
+
+
Category:
+
Type:
+

+ 100) { + $shortDescription .= '...'; + } + echo htmlspecialchars($shortDescription); + ?> +

+
+
    +
  • Data Owner:
  • +
  • Published: + + + + Not specified + +
  • +
+ + ['label' => 'Questionnaire / Data Dictionary', 'icon' => 'fa-clipboard-list'], + 'dspsds_filename2' => ['label' => 'Protocol / User Guide', 'icon' => 'fa-book'], + 'dspsds_filename3' => ['label' => 'Other Supporting Document', 'icon' => 'fa-file-alt'], + ]; + ?> +
+ Supporting Documents +
    + $meta): ?> + +
  • + + + + + + + + (Not provided) + +
  • + +
+
+ + + hasPermission($person_id, $ds['pkdspsds_id'], 'Read'); + $has_download_permission = $permissionManager->hasPermission($person_id, $ds['pkdspsds_id'], 'Download'); + } catch (Exception $e) { + error_log("Permission check error for user " . $person_id . " on DS " . $ds['pkdspsds_id'] . ": " . $e->getMessage()); + } + ?> + + + Read Access Granted + + + + + + + + Download File + + + + + + + +
+
+
+
+ +
+ +
+

No Data Sources Found

+

We couldn't find any data sources matching your criteria. Try adjusting your search term or selecting a different category.

+
+ +
diff --git a/classes/Aboutus.php b/classes/Aboutus.php new file mode 100644 index 0000000..e5913b3 --- /dev/null +++ b/classes/Aboutus.php @@ -0,0 +1,127 @@ +pdo = $pdo; + } + + /** + * Adds a new "About Us" entry to the database. + * + * @param string $title_en The English title (e.g., Vision, Mission, Goal). + * @param string $description The detailed description. + * @param int $reg_by The ID of the user who registered this entry (from ist_tbl_users). + * @param int $fkisp_id_of The ID of the person associated with this entry (from ist_tbl_people). + * @return bool True on success. + * @throws Exception If a database error occurs or title already exists. + */ + public function addAboutUs(string $title_en, string $description, int $reg_by, int $fkisp_id_of): bool { + $sql = "INSERT INTO dsps_tbl_dspsabout (dspsabout_title_en, dspsabout_description, dspsabout_reg_by, fkisp_id_of) + VALUES (:title_en, :description, :reg_by, :fkisp_id_of)"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':title_en', $title_en); + $stmt->bindParam(':description', $description); + $stmt->bindParam(':reg_by', $reg_by); + $stmt->bindParam(':fkisp_id_of', $fkisp_id_of); + return $stmt->execute(); + } catch (PDOException $e) { + if ($e->getCode() == '23000') { // Integrity constraint violation (e.g., duplicate title if UNIQUE) + throw new Exception("An 'About Us' entry with this title already exists."); + } + error_log("Error adding About Us entry: " . $e->getMessage()); + throw new Exception("Could not add About Us entry. Please try again later."); + } + } + + /** + * Updates an existing "About Us" entry. + * + * @param int $id The ID of the entry to update. + * @param string $title_en The new English title. + * @param string $description The new description. + * @param int $mod_by The ID of the user who modified this entry. + * @param int $fkisp_id_of The ID of the person associated with this entry. + * @return bool True on success. + * @throws Exception If a database error occurs or title already exists. + */ + public function updateAboutUs(int $id, string $title_en, string $description, int $mod_by, int $fkisp_id_of): bool { + $sql = "UPDATE dsps_tbl_dspsabout + SET dspsabout_title_en = :title_en, dspsabout_description = :description, + dspsabout_mod_datetime = CURRENT_TIMESTAMP, dspsabout_reg_by = :mod_by, fkisp_id_of = :fkisp_id_of + WHERE pkdspsabout_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':title_en', $title_en); + $stmt->bindParam(':description', $description); + $stmt->bindParam(':mod_by', $mod_by); + $stmt->bindParam(':fkisp_id_of', $fkisp_id_of); + $stmt->bindParam(':id', $id); + return $stmt->execute(); + } catch (PDOException $e) { + if ($e->getCode() == '23000') { + throw new Exception("An 'About Us' entry with this title already exists."); + } + error_log("Error updating About Us entry (ID: $id): " . $e->getMessage()); + throw new Exception("Could not update About Us entry. Please try again later."); + } + } + + /** + * Deletes an "About Us" entry. + * + * @param int $id The ID of the entry to delete. + * @return bool True on success. + * @throws Exception If a database error occurs. + */ + public function deleteAboutUs(int $id): bool { + $sql = "DELETE FROM dsps_tbl_dspsabout WHERE pkdspsabout_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':id', $id); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error deleting About Us entry (ID: $id): " . $e->getMessage()); + throw new Exception("Could not delete About Us entry. Please try again later."); + } + } + + /** + * Retrieves a single "About Us" entry by its ID. + * + * @param int $id The ID of the entry. + * @return array|false The entry data as an associative array, or false if not found. + * @throws Exception If a database error occurs. + */ + public function getAboutUsById(int $id) { + $sql = "SELECT * FROM dsps_tbl_dspsabout WHERE pkdspsabout_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':id', $id); + $stmt->execute(); + return $stmt->fetch(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching About Us entry by ID ($id): " . $e->getMessage()); + throw new Exception("Could not retrieve About Us entry. Please try again later."); + } + } + + /** + * Retrieves all "About Us" entries. + * + * @return array An array of "About Us" entry data. + * @throws Exception If a database error occurs. + */ + public function getAllAboutUs(): array { + $sql = "SELECT * FROM dsps_tbl_dspsabout ORDER BY dspsabout_reg_datetime ASC"; + try { + $stmt = $this->pdo->query($sql); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching all About Us entries: " . $e->getMessage()); + throw new Exception("Could not retrieve About Us entries. Please try again later."); + } + } +} diff --git a/classes/Announcement.php b/classes/Announcement.php new file mode 100644 index 0000000..0e68440 --- /dev/null +++ b/classes/Announcement.php @@ -0,0 +1,213 @@ +pdo = $pdo; + $this->uploadDir = __DIR__ . '/../uploads/announcements/'; + // Ensure upload directory exists + if (!is_dir($this->uploadDir) && !mkdir($this->uploadDir, 0775, true) && !is_dir($this->uploadDir)) { + throw new RuntimeException('Unable to create announcements upload directory.'); + } + } + + /** + * Adds a new announcement to the database. + * + * @param string $title The title of the announcement. + * @param string $description The full description of the announcement. + * @param string|null $photopath The filename of the uploaded photo, or null if no photo. + * @param string $status The status of the announcement (e.g., 'Draft', 'Published', 'Archived'). + * @param int $reg_by The ID of the user who registered the announcement. + * @return bool True on success, false on failure. + * @throws Exception If a database error occurs. + */ + public function addAnnouncement(string $title, string $description, ?string $photopath, string $status, int $reg_by): bool { + $sql = "INSERT INTO dsps_tbl_announcement (dspsann_title, dspsann_description, dspsann_photopath, dspsann_status, dspsann_reg_by) + VALUES (:title, :description, :photopath, :status, :reg_by)"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':title', $title); + $stmt->bindParam(':description', $description); + $stmt->bindParam(':photopath', $photopath); + $stmt->bindParam(':status', $status); + $stmt->bindParam(':reg_by', $reg_by); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error adding announcement: " . $e->getMessage()); + throw new Exception("Could not add announcement. Please try again later."); + } + } + + /** + * Updates an existing announcement in the database. + * + * @param int $id The ID of the announcement to update. + * @param string $title The new title. + * @param string $description The new description. + * @param string|null $photopath The new filename of the photo, or null. + * @param string $status The new status. + * @param int $mod_by The ID of the user who modified the announcement. + * @return bool True on success, false on failure. + * @throws Exception If a database error occurs. + */ + public function updateAnnouncement(int $id, string $title, string $description, ?string $photopath, string $status, int $mod_by): bool { + $sql = "UPDATE dsps_tbl_announcement + SET dspsann_title = :title, dspsann_description = :description, dspsann_photopath = :photopath, + dspsann_status = :status, dspsann_mod_datetime = CURRENT_TIMESTAMP, dspsann_reg_by = :mod_by + WHERE pkdspsann_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':title', $title); + $stmt->bindParam(':description', $description); + $stmt->bindParam(':photopath', $photopath); + $stmt->bindParam(':status', $status); + $stmt->bindParam(':mod_by', $mod_by); + $stmt->bindParam(':id', $id); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error updating announcement (ID: $id): " . $e->getMessage()); + throw new Exception("Could not update announcement. Please try again later."); + } + } + + /** + * Deletes an announcement from the database and its associated photo file. + * + * @param int $id The ID of the announcement to delete. + * @return bool True on success, false on failure. + * @throws Exception If a database error occurs. + */ + public function deleteAnnouncement(int $id): bool { + // First, get the photo path to delete the file + $announcement = $this->getAnnouncementById($id); + if ($announcement && !empty($announcement['dspsann_photopath'])) { + $filePath = $this->uploadDir . $announcement['dspsann_photopath']; + if (file_exists($filePath)) { + unlink($filePath); // Delete the file + } + } + + $sql = "DELETE FROM dsps_tbl_announcement WHERE pkdspsann_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':id', $id); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error deleting announcement (ID: $id): " . $e->getMessage()); + throw new Exception("Could not delete announcement. Please try again later."); + } + } + + /** + * Retrieves a single announcement by its ID. + * + * @param int $id The ID of the announcement. + * @return array|false The announcement data as an associative array, or false if not found. + * @throws Exception If a database error occurs. + */ + public function getAnnouncementById(int $id) { + $sql = "SELECT * FROM dsps_tbl_announcement WHERE pkdspsann_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':id', $id); + $stmt->execute(); + return $stmt->fetch(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching announcement by ID ($id): " . $e->getMessage()); + throw new Exception("Could not retrieve announcement. Please try again later."); + } + } + + /** + * Retrieves all announcements, optionally filtered by status. + * + * @param string|null $status Optional status to filter by (e.g., 'Published'). + * @param int|null $limit Optional limit for the number of results. + * @return array An array of announcement data. + * @throws Exception If a database error occurs. + */ + public function getAllAnnouncements(?string $status = null, ?int $limit = null): array { + $sql = "SELECT * FROM dsps_tbl_announcement"; + $conditions = []; + $params = []; + + if ($status) { + $conditions[] = "dspsann_status = :status"; + $params[':status'] = $status; + } + + if (!empty($conditions)) { + $sql .= " WHERE " . implode(" AND ", $conditions); + } + + $sql .= " ORDER BY dspsann_reg_datetime DESC"; + + if ($limit) { + $sql .= " LIMIT :limit"; + $params[':limit'] = $limit; + } + + try { + $stmt = $this->pdo->prepare($sql); + foreach ($params as $key => &$val) { + $stmt->bindParam($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR); + } + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching all announcements: " . $e->getMessage()); + throw new Exception("Could not retrieve announcements. Please try again later."); + } + } + + /** + * Gets the total count of announcements. + * + * @return int The total number of announcements. + * @throws Exception If a database error occurs. + */ + public function getTotalAnnouncements(): int { + $sql = "SELECT COUNT(*) FROM dsps_tbl_announcement"; + try { + $stmt = $this->pdo->query($sql); + return $stmt->fetchColumn(); + } catch (PDOException $e) { + error_log("Error getting total announcements count: " . $e->getMessage()); + throw new Exception("Could not retrieve announcement count. Please try again later."); + } + } + + /** + * Handles the upload of an announcement photo. + * + * @param array $file The $_FILES array for the uploaded photo. + * @return string The unique filename of the uploaded photo. + * @throws Exception If the upload fails or file type is invalid. + */ + public function handlePhotoUpload(array $file): string { + if ($file['error'] !== UPLOAD_ERR_OK) { + throw new Exception('File upload error: ' . $file['error']); + } + + $allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->file($file['tmp_name']); + + if (!in_array($mimeType, $allowedTypes)) { + throw new Exception('Invalid file type. Only JPEG, PNG, and GIF images are allowed.'); + } + + $extension = pathinfo($file['name'], PATHINFO_EXTENSION); + $uniqueFilename = uniqid('announcement_') . '.' . $extension; + $destination = $this->uploadDir . $uniqueFilename; + + if (!move_uploaded_file($file['tmp_name'], $destination)) { + throw new Exception('Failed to move uploaded file.'); + } + + return $uniqueFilename; + } +} diff --git a/classes/Classifications.php b/classes/Classifications.php new file mode 100644 index 0000000..3924f47 --- /dev/null +++ b/classes/Classifications.php @@ -0,0 +1,293 @@ +pdo = $pdo; + } + + // --- Data Type Management (dsps_tbl_typedatasource) --- + + /** + * Adds a new data type. + * + * @param string $name_en English name of the data type. + * @param string|null $name_kh Khmer name of the data type. + * @param int $reg_by User ID who registered it. + * @return bool True on success. + * @throws Exception If a database error occurs or name already exists. + */ + public function addDataType(string $name_en, ?string $name_kh, int $reg_by): bool { + $sql = "INSERT INTO dsps_tbl_typedatasource (dspstds_name_en, dspstds_name_kh, dspstds_reg_by) + VALUES (:name_en, :name_kh, :reg_by)"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':name_en', $name_en); + $stmt->bindParam(':name_kh', $name_kh); + $stmt->bindParam(':reg_by', $reg_by); + return $stmt->execute(); + } catch (PDOException $e) { + if ($e->getCode() == '23000') { // Integrity constraint violation (duplicate entry) + throw new Exception("Data Type with this English name already exists."); + } + error_log("Error adding data type: " . $e->getMessage()); + throw new Exception("Could not add data type. Please try again later."); + } + } + + /** + * Updates an existing data type. + * + * @param int $id ID of the data type to update. + * @param string $name_en New English name. + * @param string|null $name_kh New Khmer name. + * @param int $mod_by User ID who modified it. + * @return bool True on success. + * @throws Exception If a database error occurs or name already exists. + */ + public function updateDataType(int $id, string $name_en, ?string $name_kh, int $mod_by): bool { + $sql = "UPDATE dsps_tbl_typedatasource + SET dspstds_name_en = :name_en, dspstds_name_kh = :name_kh, + dspstds_mod_datetime = CURRENT_TIMESTAMP, dspstds_reg_by = :mod_by + WHERE pkdspstds_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':name_en', $name_en); + $stmt->bindParam(':name_kh', $name_kh); + $stmt->bindParam(':mod_by', $mod_by); + $stmt->bindParam(':id', $id); + return $stmt->execute(); + } catch (PDOException $e) { + if ($e->getCode() == '23000') { + throw new Exception("Data Type with this English name already exists."); + } + error_log("Error updating data type (ID: $id): " . $e->getMessage()); + throw new Exception("Could not update data type. Please try again later."); + } + } + + /** + * Deletes a data type. + * + * @param int $id ID of the data type to delete. + * @return bool True on success. + * @throws Exception If a database error occurs or data type is in use. + */ + public function deleteDataType(int $id): bool { + // Check if any data sources are using this data type + $checkSql = "SELECT COUNT(*) FROM dsps_tbl_datasource WHERE fkdspstds_id = :id"; + $stmtCheck = $this->pdo->prepare($checkSql); + $stmtCheck->bindParam(':id', $id); + $stmtCheck->execute(); + if ($stmtCheck->fetchColumn() > 0) { + throw new Exception("Cannot delete Data Type: It is currently used by one or more data sources."); + } + + $sql = "DELETE FROM dsps_tbl_typedatasource WHERE pkdspstds_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':id', $id); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error deleting data type (ID: $id): " . $e->getMessage()); + throw new Exception("Could not delete data type. Please try again later."); + } + } + + /** + * Retrieves a single data type by ID. + * + * @param int $id ID of the data type. + * @return array|false Data type data or false if not found. + * @throws Exception If a database error occurs. + */ + public function getDataTypeById(int $id) { + $sql = "SELECT * FROM dsps_tbl_typedatasource WHERE pkdspstds_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':id', $id); + $stmt->execute(); + return $stmt->fetch(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching data type by ID ($id): " . $e->getMessage()); + throw new Exception("Could not retrieve data type. Please try again later."); + } + } + + /** + * Retrieves all data types. + * + * @return array An array of data type data. + * @throws Exception If a database error occurs. + */ + public function getAllDataTypes(): array { + $sql = "SELECT * FROM dsps_tbl_typedatasource ORDER BY dspstds_name_en ASC"; + try { + $stmt = $this->pdo->query($sql); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching all data types: " . $e->getMessage()); + throw new Exception("Could not retrieve data types. Please try again later."); + } + } + + /** + * Gets the total count of data types. + * + * @return int The total number of data types. + * @throws Exception If a database error occurs. + */ + public function getTotalDataTypes(): int { + $sql = "SELECT COUNT(*) FROM dsps_tbl_typedatasource"; + try { + $stmt = $this->pdo->query($sql); + return $stmt->fetchColumn(); + } catch (PDOException $e) { + error_log("Error getting total data types count: " . $e->getMessage()); + throw new Exception("Could not retrieve data type count. Please try again later."); + } + } + + // --- Category Management (dsps_tbl_dspscategory) --- + + /** + * Adds a new category. + * + * @param string $title_en English title of the category. + * @param string|null $details Details about the category. + * @param int $reg_by User ID who registered it. + * @return bool True on success. + * @throws Exception If a database error occurs or title already exists. + */ + public function addCategory(string $title_en, ?string $details, int $reg_by): bool { + $sql = "INSERT INTO dsps_tbl_dspscategory (dspscate_title_en, dspscate_details, dspscate_reg_by) + VALUES (:title_en, :details, :reg_by)"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':title_en', $title_en); + $stmt->bindParam(':details', $details); + $stmt->bindParam(':reg_by', $reg_by); + return $stmt->execute(); + } catch (PDOException $e) { + if ($e->getCode() == '23000') { + throw new Exception("Category with this English title already exists."); + } + error_log("Error adding category: " . $e->getMessage()); + throw new Exception("Could not add category. Please try again later."); + } + } + + /** + * Updates an existing category. + * + * @param int $id ID of the category to update. + * @param string $title_en New English title. + * @param string|null $details New details. + * @param int $mod_by User ID who modified it. + * @return bool True on success. + * @throws Exception If a database error occurs or title already exists. + */ + public function updateCategory(int $id, string $title_en, ?string $details, int $mod_by): bool { + $sql = "UPDATE dsps_tbl_dspscategory + SET dspscate_title_en = :title_en, dspscate_details = :details, + dspscate_mod_datetime = CURRENT_TIMESTAMP, dspscate_reg_by = :mod_by + WHERE pkdspscate_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':title_en', $title_en); + $stmt->bindParam(':details', $details); + $stmt->bindParam(':mod_by', $mod_by); + $stmt->bindParam(':id', $id); + return $stmt->execute(); + } catch (PDOException $e) { + if ($e->getCode() == '23000') { + throw new Exception("Category with this English title already exists."); + } + error_log("Error updating category (ID: $id): " . $e->getMessage()); + throw new Exception("Could not update category. Please try again later."); + } + } + + /** + * Deletes a category. + * + * @param int $id ID of the category to delete. + * @return bool True on success. + * @throws Exception If a database error occurs or category is in use. + */ + public function deleteCategory(int $id): bool { + // Check if any data sources are using this category + $checkSql = "SELECT COUNT(*) FROM dsps_tbl_datasource WHERE fkdspscate_id = :id"; + $stmtCheck = $this->pdo->prepare($checkSql); + $stmtCheck->bindParam(':id', $id); + $stmtCheck->execute(); + if ($stmtCheck->fetchColumn() > 0) { + throw new Exception("Cannot delete Category: It is currently used by one or more data sources."); + } + + $sql = "DELETE FROM dsps_tbl_dspscategory WHERE pkdspscate_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':id', $id); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error deleting category (ID: $id): " . $e->getMessage()); + throw new Exception("Could not delete category. Please try again later."); + } + } + + /** + * Retrieves a single category by ID. + * + * @param int $id ID of the category. + * @return array|false Category data or false if not found. + * @throws Exception If a database error occurs. + */ + public function getCategoryById(int $id) { + $sql = "SELECT * FROM dsps_tbl_dspscategory WHERE pkdspscate_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':id', $id); + $stmt->execute(); + return $stmt->fetch(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching category by ID ($id): " . $e->getMessage()); + throw new Exception("Could not retrieve category. Please try again later."); + } + } + + /** + * Retrieves all categories. + * + * @return array An array of category data. + * @throws Exception If a database error occurs. + */ + public function getAllCategories(): array { + $sql = "SELECT * FROM dsps_tbl_dspscategory ORDER BY dspscate_title_en ASC"; + try { + $stmt = $this->pdo->query($sql); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching all categories: " . $e->getMessage()); + throw new Exception("Could not retrieve categories. Please try again later."); + } + } + + /** + * Gets the total count of categories. + * + * @return int The total number of categories. + * @throws Exception If a database error occurs. + */ + public function getTotalCategories(): int { + $sql = "SELECT COUNT(*) FROM dsps_tbl_dspscategory"; + try { + $stmt = $this->pdo->query($sql); + return $stmt->fetchColumn(); + } catch (PDOException $e) { + error_log("Error getting total categories count: " . $e->getMessage()); + throw new Exception("Could not retrieve category count. Please try again later."); + } + } +} diff --git a/classes/Contactus.php b/classes/Contactus.php new file mode 100644 index 0000000..5147681 --- /dev/null +++ b/classes/Contactus.php @@ -0,0 +1,172 @@ +pdo = $pdo; + } + + /** + * Submits a new feedback message from a user/visitor. + * + * @param string $name The name of the person submitting feedback. + * @param string|null $email The email of the person, if provided. + * @param string $body_text The main body of the feedback message. + * @param string|null $client_ip The IP address of the client. + * @return bool True on success. + * @throws Exception If a database error occurs. + */ + public function submitFeedback(string $name, ?string $email, string $body_text, ?string $client_ip): bool { + $sql = "INSERT INTO dsps_tbl_feedback (dspsfb_name, dspsfb_email, dspsfb_body_text, dspsfb_client_ip, dspsfb_status) + VALUES (:name, :email, :body_text, :client_ip, 'New')"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':name', $name); + $stmt->bindParam(':email', $email); + $stmt->bindParam(':body_text', $body_text); + $stmt->bindParam(':client_ip', $client_ip); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error submitting feedback: " . $e->getMessage()); + throw new Exception("Could not submit feedback. Please try again later."); + } + } + + /** + * Allows a DAC Staff user to respond to a feedback message. + * + * @param int $feedback_id The ID of the feedback message to respond to. + * @param string $respond_text The response text from the DAC Staff. + * @param string $status The new status of the feedback (e.g., 'In Progress', 'Resolved'). + * @param int $res_by The user ID of the DAC Staff who responded. + * @return bool True on success. + * @throws Exception If a database error occurs. + */ + public function respondToFeedback(int $feedback_id, string $respond_text, string $status, int $res_by): bool { + $sql = "UPDATE dsps_tbl_feedback + SET dspsfb_respond_text = :respond_text, dspsfb_status = :status, + dspsfb_res_datetime = CURRENT_TIMESTAMP, dspsfb_res_by = :res_by + WHERE pkdspsfb_id = :feedback_id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':respond_text', $respond_text); + $stmt->bindParam(':status', $status); + $stmt->bindParam(':res_by', $res_by); + $stmt->bindParam(':feedback_id', $feedback_id); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error responding to feedback (ID: $feedback_id): " . $e->getMessage()); + throw new Exception("Could not respond to feedback. Please try again later."); + } + } + + /** + * Deletes a feedback message. + * + * @param int $feedback_id The ID of the feedback message to delete. + * @return bool True on success. + * @throws Exception If a database error occurs. + */ + public function deleteFeedback(int $feedback_id): bool { + $sql = "DELETE FROM dsps_tbl_feedback WHERE pkdspsfb_id = :feedback_id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':feedback_id', $feedback_id); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error deleting feedback (ID: $feedback_id): " . $e->getMessage()); + throw new Exception("Could not delete feedback. Please try again later."); + } + } + + /** + * Retrieves a single feedback message by its ID. + * + * @param int $feedback_id The ID of the feedback message. + * @return array|false The feedback data as an associative array, or false if not found. + * @throws Exception If a database error occurs. + */ + public function getFeedbackById(int $feedback_id) { + $sql = "SELECT * FROM dsps_tbl_feedback WHERE pkdspsfb_id = :feedback_id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':feedback_id', $feedback_id); + $stmt->execute(); + return $stmt->fetch(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching feedback by ID ($feedback_id): " . $e->getMessage()); + throw new Exception("Could not retrieve feedback. Please try again later."); + } + } + + /** + * Retrieves all feedback messages, optionally filtered by status. + * + * @param string|null $status Optional status to filter by (e.g., 'New', 'Resolved'). + * @return array An array of feedback data. + * @throws Exception If a database error occurs. + */ + public function getAllFeedback(?string $status = null): array { + $sql = "SELECT * FROM dsps_tbl_feedback"; + $conditions = []; + $params = []; + + if ($status) { + $conditions[] = "dspsfb_status = :status"; + $params[':status'] = $status; + } + + if (!empty($conditions)) { + $sql .= " WHERE " . implode(" AND ", $conditions); + } + + $sql .= " ORDER BY dspsfb_reg_datetime DESC"; + + try { + $stmt = $this->pdo->prepare($sql); + foreach ($params as $key => &$val) { + $stmt->bindParam($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR); + } + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching all feedback: " . $e->getMessage()); + throw new Exception("Could not retrieve feedback messages. Please try again later."); + } + } + + /** + * Gets the total count of feedback messages, optionally filtered by status. + * + * @param string|null $status Optional status to filter by. + * @return int The total number of feedback messages. + * @throws Exception If a database error occurs. + */ + public function getTotalFeedback(?string $status = null): int { + $sql = "SELECT COUNT(*) FROM dsps_tbl_feedback"; + $conditions = []; + $params = []; + + if ($status) { + $conditions[] = "dspsfb_status = :status"; + $params[':status'] = $status; + } + + if (!empty($conditions)) { + $sql .= " WHERE " . implode(" AND ", $conditions); + } + + try { + $stmt = $this->pdo->prepare($sql); + foreach ($params as $key => &$val) { + $stmt->bindParam($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR); + } + $stmt->execute(); + return $stmt->fetchColumn(); + } catch (PDOException $e) { + error_log("Error getting total feedback count: " . $e->getMessage()); + throw new Exception("Could not retrieve feedback count. Please try again later."); + } + } +} diff --git a/classes/DataSource.php b/classes/DataSource.php new file mode 100644 index 0000000..3087b7b --- /dev/null +++ b/classes/DataSource.php @@ -0,0 +1,1489 @@ + ['csv', 'json', 'pdf', 'xls', 'xlsx'], + 'mimes' => [ + 'text/csv', + 'text/plain', + 'application/json', + 'application/pdf', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ], + 'description' => 'CSV, JSON, PDF, XLS, XLSX', + ]; + private const TYPE_SPECIFIC_RULES = [ + 'csv' => [ + 'extensions' => ['csv'], + 'mimes' => ['text/csv', 'text/plain', 'application/vnd.ms-excel'], + 'description' => 'CSV files (.csv)', + ], + 'json' => [ + 'extensions' => ['json'], + 'mimes' => ['application/json', 'text/json', 'text/plain'], + 'description' => 'JSON files (.json)', + ], + 'pdf' => [ + 'extensions' => ['pdf'], + 'mimes' => ['application/pdf'], + 'description' => 'PDF documents (.pdf)', + ], + 'xls' => [ + 'extensions' => ['xls'], + 'mimes' => ['application/vnd.ms-excel'], + 'description' => 'Excel 97-2003 (.xls)', + ], + 'xlsx' => [ + 'extensions' => ['xlsx'], + 'mimes' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + 'description' => 'Excel (.xlsx)', + ], + 'dta' => [ + 'extensions' => ['dta'], + 'mimes' => [ + 'application/x-stata', + 'application/stata', + 'application/octet-stream', + ], + 'description' => 'Stata dataset (.dta)', + ], + 'stata' => [ + 'extensions' => ['dta'], + 'mimes' => [ + 'application/x-stata', + 'application/stata', + 'application/octet-stream', + ], + 'description' => 'Stata dataset (.dta)', + ], + 'sav' => [ + 'extensions' => ['sav'], + 'mimes' => [ + 'application/octet-stream', + 'application/vnd.spss.sav', + ], + 'description' => 'SPSS dataset (.sav)', + ], + ]; + + public function __construct(PDO $pdo) { + $this->pdo = $pdo; + + // Correctly define the absolute path from the project root. + // __DIR__ points to the 'classes' directory. + // dirname(__DIR__) moves up one level to 'dsp26072025'. + $projectRoot = dirname(__DIR__); + $this->uploadDir = $projectRoot . '/uploads/datasources/'; + + // Ensure upload directory exists + if (!is_dir($this->uploadDir)) { + if (!mkdir($this->uploadDir, 0777, true)) { + // If it still fails, there is a deeper permissions issue + die("Error: Failed to create directory. Please check permissions for " . $this->uploadDir); + } + } + } + + // Public getter for upload directory (needed for file deletion) + public function getUploadDir(): string { + return $this->uploadDir; + } + + // --- Data Source Management (dsps_tbl_datasource) --- + + /** + * Adds a new data source. + * + * @param int $fkdspstds_id FK to dsps_tbl_typedatasource. + * @param int $fkdspscate_id FK to dsps_tbl_dspscategory. + * @param int $fkisp_id_of FK to ist_tbl_people (Data Owner). + * @param string|null $filename Primary file path/name or API endpoint URL. + * @param string $title_en English title. + * @param string|null $title_kh Khmer title. + * @param string|null $description Description. + * @param string $status Status ('Active', 'Inactive', 'Pending Review', 'Published'). + * @param int $reg_by User ID who registered it. + * @param string|null $filename1 Optional secondary file path. + * @param string|null $filename2 Optional tertiary file path. + * @param string|null $filename3 Optional quaternary file path. + * @return bool True on success. + * @throws Exception If a database error occurs. + */ + public function addDataSource( + int $fkdspstds_id, + int $fkdspscate_id, + int $fkisp_id_of, + ?string $filename, + string $title_en, + ?string $title_kh, + ?string $description, + string $status, + int $reg_by, + ?string $filename1 = null, + ?string $filename2 = null, + ?string $filename3 = null + ): bool { + // Determine public_date based on status. If 'Published', set to current date, otherwise NULL. + $publicDateSql = ($status === 'Published') ? "CURRENT_DATE" : "NULL"; + + $sql = "INSERT INTO dsps_tbl_datasource (fkdspstds_id, fkdspscate_id, fkisp_id_of, dspsds_filename, + dspsds_title_en, dspsds_title_kh, dspsds_description, + dspsds_status, dspsds_reg_by, dspsds_public_date, + dspsds_filename1, dspsds_filename2, dspsds_filename3) + VALUES (:fkdspstds_id, :fkdspscate_id, :fkisp_id_of, :filename, + :title_en, :title_kh, :description, :status, :reg_by, + " . $publicDateSql . ", :filename1, :filename2, :filename3)"; // Directly inject CURRENT_DATE or NULL + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':fkdspstds_id', $fkdspstds_id, PDO::PARAM_INT); + $stmt->bindParam(':fkdspscate_id', $fkdspscate_id, PDO::PARAM_INT); + $stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT); + $stmt->bindParam(':filename', $filename); + $stmt->bindParam(':title_en', $title_en); + $stmt->bindParam(':title_kh', $title_kh); + $stmt->bindParam(':description', $description); + $stmt->bindParam(':status', $status); + $stmt->bindParam(':reg_by', $reg_by, PDO::PARAM_INT); + $stmt->bindParam(':filename1', $filename1); + $stmt->bindParam(':filename2', $filename2); + $stmt->bindParam(':filename3', $filename3); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error adding data source: " . $e->getMessage()); + throw new Exception("Could not add data source. Please try again later."); + } + } + + /** + * Updates an existing data source. + * + * @param int $id ID of the data source to update. + * @param int $fkdspstds_id FK to dsps_tbl_typedatasource. + * @param int $fkdspscate_id FK to dsps_tbl_dspscategory. + * @param int $fkisp_id_of FK to ist_tbl_people (Data Owner). + * @param string|null $filename File path/name or API endpoint URL. + * @param string $title_en English title. + * @param string|null $title_kh Khmer title. + * @param string|null $description Description. + * @param string $status Status ('Active', 'Inactive', 'Pending Review', 'Published'). + * @param int $mod_by User ID who modified it. + * @param string|null $filename1 Optional secondary file path. + * @param string|null $filename2 Optional tertiary file path. + * @param string|null $filename3 Optional quaternary file path. + * @return bool True on success. + * @throws Exception If a database error occurs. + */ + public function updateDataSource( + int $id, + int $fkdspstds_id, + int $fkdspscate_id, + int $fkisp_id_of, + ?string $filename, + string $title_en, + ?string $title_kh, + ?string $description, + string $status, + int $mod_by, + ?string $filename1 = null, + ?string $filename2 = null, + ?string $filename3 = null + ): bool { + // Check current status to decide on dspsds_public_date update + $currentDataSource = $this->getDataSourceById($id); + $publicDateUpdate = ""; + if ($status === 'Published' && ($currentDataSource['dspsds_status'] !== 'Published' || empty($currentDataSource['dspsds_public_date']))) { + $publicDateUpdate = ", dspsds_public_date = CURRENT_DATE"; + } elseif ($status !== 'Published' && !empty($currentDataSource['dspsds_public_date'])) { + $publicDateUpdate = ", dspsds_public_date = NULL"; + } + + $sql = "UPDATE dsps_tbl_datasource + SET fkdspstds_id = :fkdspstds_id, fkdspscate_id = :fkdspscate_id, fkisp_id_of = :fkisp_id_of, + dspsds_filename = :filename, dspsds_title_en = :title_en, dspsds_title_kh = :title_kh, + dspsds_description = :description, dspsds_status = :status, + dspsds_filename1 = :filename1, dspsds_filename2 = :filename2, dspsds_filename3 = :filename3, + dspsds_mod_datetime = CURRENT_TIMESTAMP, dspsds_reg_by = :mod_by + {$publicDateUpdate} + WHERE pkdspsds_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':fkdspstds_id', $fkdspstds_id, PDO::PARAM_INT); + $stmt->bindParam(':fkdspscate_id', $fkdspscate_id, PDO::PARAM_INT); + $stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT); + $stmt->bindParam(':filename', $filename); + $stmt->bindParam(':title_en', $title_en); + $stmt->bindParam(':title_kh', $title_kh); + $stmt->bindParam(':description', $description); + $stmt->bindParam(':status', $status); + $stmt->bindParam(':filename1', $filename1); + $stmt->bindParam(':filename2', $filename2); + $stmt->bindParam(':filename3', $filename3); + $stmt->bindParam(':mod_by', $mod_by, PDO::PARAM_INT); + $stmt->bindParam(':id', $id, PDO::PARAM_INT); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error updating data source (ID: $id): " . $e->getMessage()); + throw new Exception("Could not update data source. Please try again later."); + } + } + + /** + * Deletes a data source from the database and its associated file. + * + * @param int $id The ID of the data source to delete. + * @return bool True on success. + * @throws Exception If a database error occurs. + */ + public function deleteDataSource(int $id): bool { + // Get the filename to delete the actual file + $dataSource = $this->getDataSourceById($id); + if ($dataSource) { + $fileColumns = ['dspsds_filename', 'dspsds_filename1', 'dspsds_filename2', 'dspsds_filename3']; + foreach ($fileColumns as $column) { + if (!empty($dataSource[$column])) { + $filePath = $this->uploadDir . $dataSource[$column]; + if (is_file($filePath)) { + unlink($filePath); + } + } + } + } + + $sql = "DELETE FROM dsps_tbl_datasource WHERE pkdspsds_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':id', $id); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error deleting data source (ID: $id): " . $e->getMessage()); + throw new Exception("Could not delete data source. Please try again later."); + } + } + + /** + * Retrieves a single data source by its ID. + * + * @param int $id The ID of the data source. + * @return array|false The data source data as an associative array, or false if not found. + * @throws Exception If a database error occurs. + */ + public function getDataSourceById(int $id) { + $sql = "SELECT ds.*, dspstds.dspstds_name_en AS data_type_name, dspstds.dspstds_name_kh AS data_type_name_kh, + dspscate.dspscate_title_en AS category_name, + isp.isp_firstname_en, isp.isp_lastname_en + FROM dsps_tbl_datasource ds + JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id + JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id + JOIN ist_tbl_people isp ON ds.fkisp_id_of = isp.pkisp_id + WHERE ds.pkdspsds_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':id', $id); + $stmt->execute(); + return $stmt->fetch(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching data source by ID ($id): " . $e->getMessage()); + throw new Exception("Could not retrieve data source. Please try again later."); + } + } + + /** + * Retrieves data sources based on various filters. + * + * @param int|null $owner_person_id Optional: Filter by data owner's person ID. + * @param string|null $status Optional: Filter by status (e.g., 'Active', 'Pending Review'). + * @param int|null $category_id Optional: Filter by category ID. + * @param string|null $search_query Optional: Search by title or description. + * @param int|null $limit Optional: Limit the number of results. + * @return array An array of data source data. + * @throws Exception If a database error occurs. + */ + public function getDataSources( + ?int $owner_person_id = null, + ?string $status = null, + ?int $category_id = null, + ?string $search_query = null, + ?int $limit = null + ): array { + $sql = "SELECT ds.*, dspstds.dspstds_name_en AS data_type_name, dspstds.dspstds_name_kh AS data_type_name_kh, + dspscate.dspscate_title_en AS category_name, + isp.isp_firstname_en, isp.isp_lastname_en + FROM dsps_tbl_datasource ds + JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id + JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id + JOIN ist_tbl_people isp ON ds.fkisp_id_of = isp.pkisp_id"; + $conditions = []; + $params = []; + + // Use strict checks for null to avoid issues with 0 and empty strings + if ($owner_person_id !== null) { + $conditions[] = "ds.fkisp_id_of = :owner_person_id"; + $params[':owner_person_id'] = $owner_person_id; + } + if ($status !== null) { + $conditions[] = "ds.dspsds_status = :status"; + $params[':status'] = $status; + } + if ($category_id !== null) { + $conditions[] = "ds.fkdspscate_id = :category_id"; + $params[':category_id'] = $category_id; + } + if ($search_query !== null && $search_query !== '') { + $conditions[] = "(ds.dspsds_title_en LIKE :search_query OR ds.dspsds_description LIKE :search_query)"; + $params[':search_query'] = '%' . $search_query . '%'; + } + + if (!empty($conditions)) { + $sql .= " WHERE " . implode(" AND ", $conditions); + } + + $sql .= " ORDER BY ds.dspsds_reg_datetime DESC"; + + // A safer way to handle LIMIT is to validate and concatenate the integer + // directly, as not all PDO drivers support binding LIMIT values. + if ($limit !== null && is_int($limit) && $limit > 0) { + $sql .= " LIMIT " . $limit; + } + + try { + $stmt = $this->pdo->prepare($sql); + // Pass the parameters array directly to execute() + $stmt->execute($params); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching data sources: " . $e->getMessage()); + throw new Exception("Could not retrieve data sources. Please try again later."); + } + } + + /** + * Gets the total count of data sources. + * + * @return int The total number of data sources. + * @throws Exception If a database error occurs. + */ + public function getTotalDataSources(): int { + $sql = "SELECT COUNT(*) FROM dsps_tbl_datasource"; + try { + $stmt = $this->pdo->query($sql); + return $stmt->fetchColumn(); + } catch (PDOException $e) { + error_log("Error getting total data sources count: " . $e->getMessage()); + throw new Exception("Could not retrieve data source count. Please try again later."); + } + } + + /** + * Handles the upload of a data source file. + * + * @param array $file The $_FILES array for the uploaded file. + * @return string The unique filename of the uploaded file. + * @throws Exception If the upload fails. + */ + public function handleDataSourceFileUpload(array $file, ?array $fileRules = null): string { + if ($file['error'] !== UPLOAD_ERR_OK) { + throw new Exception('File upload error: ' . $file['error']); + } + + $rules = $fileRules ?? self::DEFAULT_FILE_RULES; + $allowedMimeTypes = $rules['mimes'] ?? self::DEFAULT_FILE_RULES['mimes']; + $allowedExtensions = $rules['extensions'] ?? self::DEFAULT_FILE_RULES['extensions']; + $description = $rules['description'] ?? self::DEFAULT_FILE_RULES['description']; + if (($file['size'] ?? 0) <= 0) { + throw new Exception('Uploaded file is empty or missing.'); + } + + error_log(sprintf( + '[DataSource] Upload requested: name=%s size=%s mime=%s rules=%s', + $file['name'] ?? '', + $file['size'] ?? 'unknown', + $file['type'] ?? 'unknown', + $description + )); + + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->file($file['tmp_name']); + + if (!in_array($mimeType, $allowedMimeTypes, true)) { + throw new Exception('Invalid file type. Allowed formats: ' . $description); + } + + $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + + if (!in_array($extension, array_map('strtolower', $allowedExtensions), true)) { + throw new Exception('Invalid file extension. Allowed formats: ' . $description); + } + + $originalStem = pathinfo($file['name'], PATHINFO_FILENAME); + $slug = $this->slugifyFilename($originalStem); + $uniqueFilename = 'datasource_' . uniqid(); + if (!empty($slug)) { + $uniqueFilename .= '_' . $slug; + } + $uniqueFilename .= '.' . $extension; + $destination = $this->uploadDir . $uniqueFilename; + + if (!move_uploaded_file($file['tmp_name'], $destination)) { + $moved = false; + if (PHP_SAPI === 'cli' || defined('STDIN')) { + $moved = rename($file['tmp_name'], $destination); + } + if (!$moved) { + error_log(sprintf('[DataSource] Failed to move uploaded file into %s (tmp=%s)', $destination, $file['tmp_name'])); + throw new Exception('Failed to move uploaded file.'); + } + } + + error_log(sprintf('[DataSource] File stored as %s', $uniqueFilename)); + return $uniqueFilename; + } + private function resolvePrimaryFileRules(?string $typeName): array { + if (!$typeName) { + return self::DEFAULT_FILE_RULES; + } + $key = strtolower(trim($typeName)); + if (isset(self::TYPE_SPECIFIC_RULES[$key])) { + return self::TYPE_SPECIFIC_RULES[$key]; + } + foreach (self::TYPE_SPECIFIC_RULES as $alias => $rules) { + if (str_contains($alias, $key) || str_contains($key, $alias)) { + return $rules; + } + } + // fallback to defaults but include description referencing requested type + return self::DEFAULT_FILE_RULES; + } + + public function getPrimaryFileRulesForType(?string $typeName): array { + return $this->resolvePrimaryFileRules($typeName); + } + + private function slugifyFilename(?string $value): string { + if (!$value) { + return ''; + } + $value = strtolower($value); + // Replace non-alphanumeric characters with hyphens. + $value = preg_replace('/[^a-z0-9]+/i', '-', $value); + $value = trim($value ?? '', '-'); + if ($value === '') { + return ''; + } + // Limit slug length to avoid overlong filenames. + return substr($value, 0, 50); + } + + // --- Data Source Permission Management (dsps_tbl_datasource_permission) --- + + /** + * Requests permission for a data source for a specific user. + * + * @param int $fkdspsds_id The ID of the data source. + * @param int $fkisp_id_of The person ID of the user requesting permission. + * @param string $permission The type of permission ('Read', 'Download', 'Analyze'). + * @param string|null $notes Any notes from the requester. + * @param int $reg_by The user ID who initiated the request (usually the data user themselves). + * @return bool True on success. + * @throws Exception If a database error occurs or a pending request already exists. + */ + public function requestDataSourcePermission( + int $fkdspsds_id, + int $fkisp_id_of, + string $permission, + ?string $notes, + int $reg_by, + ?string $proofPath = null + ): bool { + // Check if a pending request already exists for this user and data source + $checkSql = "SELECT COUNT(*) FROM dsps_tbl_datasource_permission + WHERE fkdspsds_id = :fkdspsds_id AND fkisp_id_of = :fkisp_id_of AND dspsdsp_status = 'Pending'"; + $stmtCheck = $this->pdo->prepare($checkSql); + $stmtCheck->bindParam(':fkdspsds_id', $fkdspsds_id, PDO::PARAM_INT); + $stmtCheck->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT); + $stmtCheck->execute(); + if ($stmtCheck->fetchColumn() > 0) { + throw new Exception("You already have a pending permission request for this data source."); + } + + // Detect any existing permission (approved/rejected/etc.) to avoid duplicate key violations + $existingSql = "SELECT dspsdsp_status FROM dsps_tbl_datasource_permission + WHERE fkdspsds_id = :fkdspsds_id AND fkisp_id_of = :fkisp_id_of LIMIT 1"; + $stmtExisting = $this->pdo->prepare($existingSql); + $stmtExisting->bindParam(':fkdspsds_id', $fkdspsds_id, PDO::PARAM_INT); + $stmtExisting->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT); + $stmtExisting->execute(); + if ($row = $stmtExisting->fetch(PDO::FETCH_ASSOC)) { + $statusLabel = $row['dspsdsp_status'] ?? 'processed'; + throw new Exception("This request was already {$statusLabel}. Please check My Permissions."); + } + + $hasProofColumn = $this->ensurePermissionProofColumn(); + $columns = [ + 'fkdspsds_id', + 'fkisp_id_of', + 'dspsdsp_permission', + 'dspsdsp_notes' + ]; + $placeholders = [ + ':fkdspsds_id', + ':fkisp_id_of', + ':permission', + ':notes' + ]; + + if ($hasProofColumn) { + $columns[] = 'dspsdsp_proof_path'; + $placeholders[] = ':proof_path'; + } + + $columns[] = 'dspsdsp_status'; + $columns[] = 'dspsdsp_reg_by'; + $placeholders[] = ':status'; + $placeholders[] = ':reg_by'; + + $sql = 'INSERT INTO dsps_tbl_datasource_permission (' . implode(', ', $columns) . ') + VALUES (' . implode(', ', $placeholders) . ')'; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':fkdspsds_id', $fkdspsds_id, PDO::PARAM_INT); + $stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT); + $stmt->bindParam(':permission', $permission); + if ($notes === null) { + $stmt->bindValue(':notes', null, PDO::PARAM_NULL); + } else { + $stmt->bindValue(':notes', $notes, PDO::PARAM_STR); + } + + if ($hasProofColumn) { + if ($proofPath === null) { + $stmt->bindValue(':proof_path', null, PDO::PARAM_NULL); + } else { + $stmt->bindValue(':proof_path', $proofPath, PDO::PARAM_STR); + } + } + + $stmt->bindValue(':status', 'Pending', PDO::PARAM_STR); + $stmt->bindParam(':reg_by', $reg_by, PDO::PARAM_INT); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error requesting data source permission: " . $e->getMessage()); + throw new Exception("Could not submit permission request. Please try again later."); + } + } + + /** + * Retrieves permission requests for a specific Data Owner. + * + * @param int $owner_person_id The person ID of the Data Owner. + * @param string|null $status Optional: Filter by status ('Pending', 'Approved', 'Rejected', 'Revoked'). + * @return array An array of permission request data. + * @throws Exception If a database error occurs. + */ + public function getPermissionRequestsForOwner(int $ownerPersonId, ?string $status = null): array { + $hasProofColumn = $this->ensurePermissionProofColumn(); + $proofSelect = $hasProofColumn ? 't1.dspsdsp_proof_path' : 'NULL'; + $proofSelect .= ' AS dspsdsp_proof_path'; + + $sql = "SELECT + t1.pkdspsdsp_id, + t2.dspsds_title_en, + t3.isp_firstname_en, + t3.isp_lastname_en, + t1.dspsdsp_permission, + t1.dspsdsp_reg_datetime, + t1.dspsdsp_notes, + $proofSelect, + t1.dspsdsp_status + FROM dsps_tbl_datasource_permission AS t1 + JOIN dsps_tbl_datasource AS t2 ON t1.fkdspsds_id = t2.pkdspsds_id + JOIN ist_tbl_people AS t3 ON t1.fkisp_id_of = t3.pkisp_id + WHERE t2.fkisp_id_of = :ownerPersonId"; + + if ($status !== null) { + $sql .= " AND t1.dspsdsp_status = :status"; + } + + $sql .= " ORDER BY t1.dspsdsp_reg_datetime DESC"; + + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':ownerPersonId', $ownerPersonId, PDO::PARAM_INT); + if ($status !== null) { + $stmt->bindParam(':status', $status); + } + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Database error in getPermissionRequestsForOwner: " . $e->getMessage()); + return []; + } + } + + /** + * Retrieves a single permission request by its ID. + * Includes data source owner's person ID for authorization checks. + * + * @param int $permissionId The ID of the permission request. + * @return array|false The permission data as an associative array, or false if not found. + * @throws Exception If a database error occurs. + */ + public function getPermissionRequestById(int $permissionId) { + $sql = "SELECT dsp.*, ds.fkisp_id_of AS datasource_owner_person_id + FROM dsps_tbl_datasource_permission dsp + JOIN dsps_tbl_datasource ds ON dsp.fkdspsds_id = ds.pkdspsds_id + WHERE dsp.pkdspsdsp_id = :permission_id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':permission_id', $permissionId, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetch(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching permission request by ID ($permissionId): " . $e->getMessage()); + throw new Exception("Could not retrieve permission request details. Please try again later."); + } + } + + /** + * Retrieves all permission requests for DAC Staff, optionally filtered by status and search query. + * + * @param string|null $status_filter Optional: Filter by status ('Pending', 'Approved', 'Rejected', 'Revoked'). + * @param string|null $search_query Optional: Search by data source title, requester name, or owner name. + * @return array An array of all permission request data. + * @throws Exception If a database error occurs. + */ + public function getAllPermissionRequests(?string $status_filter = null, ?string $search_query = null): array { + $sql = "SELECT dsp.*, ds.dspsds_title_en, ds.dspsds_filename, + requester_p.isp_firstname_en AS requester_firstname, requester_p.isp_lastname_en AS requester_lastname, + owner_p.isp_firstname_en AS owner_firstname, owner_p.isp_lastname_en AS owner_lastname + FROM dsps_tbl_datasource_permission dsp + JOIN dsps_tbl_datasource ds ON dsp.fkdspsds_id = ds.pkdspsds_id + JOIN ist_tbl_people requester_p ON dsp.fkisp_id_of = requester_p.pkisp_id + JOIN ist_tbl_people owner_p ON ds.fkisp_id_of = owner_p.pkisp_id"; + $conditions = []; + $params = []; + + if ($status_filter) { + $conditions[] = "dsp.dspsdsp_status = :status_filter"; + $params[':status_filter'] = $status_filter; + } + + if ($search_query) { + $search_term = '%' . $search_query . '%'; + $conditions[] = "(ds.dspsds_title_en LIKE :search_query OR + requester_p.isp_firstname_en LIKE :search_query OR + requester_p.isp_lastname_en LIKE :search_query OR + owner_p.isp_firstname_en LIKE :search_query OR + owner_p.isp_lastname_en LIKE :search_query)"; + $params[':search_query'] = $search_term; + } + + if (!empty($conditions)) { + $sql .= " WHERE " . implode(" AND ", $conditions); + } + + $sql .= " ORDER BY dsp.dspsdsp_reg_datetime DESC"; + + try { + $stmt = $this->pdo->prepare($sql); + foreach ($params as $key => &$val) { + $stmt->bindParam($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR); + } + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching all permission requests: " . $e->getMessage()); + throw new Exception("Could not retrieve all permission requests. Please try again later."); + } + } + + + /** + * Gets the count of pending permission requests. + * + * @return int The count of pending permission requests. + * @throws Exception If a database error occurs. + */ + public function getPendingPermissionRequestsCount(): int { + $sql = "SELECT COUNT(*) FROM dsps_tbl_datasource_permission WHERE dspsdsp_status = 'Pending'"; + try { + $stmt = $this->pdo->query($sql); + return $stmt->fetchColumn(); + } catch (PDOException $e) { + error_log("Error getting pending permission requests count: " . $e->getMessage()); + throw new Exception("Could not retrieve pending permission count. Please try again later."); + } + } + + /** + * Aggregates total usage counts per data source owner (person id). + * + * @param int $limit Maximum number of owners to return. + * @return array + */ + public function getUsageByOwner(int $limit = 8): array { + $sql = "SELECT ds.fkisp_id_of AS owner_person_id, + CONCAT(p.isp_firstname_en, ' ', p.isp_lastname_en) AS owner_name, + COUNT(dsu.pkdspsdspused_id) AS usage_count + FROM dsps_tbl_datasource_used dsu + JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id + JOIN ist_tbl_people p ON ds.fkisp_id_of = p.pkisp_id + GROUP BY ds.fkisp_id_of, owner_name + ORDER BY usage_count DESC + LIMIT :limit"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching usage by owner: " . $e->getMessage()); + return []; + } + } + + /** + * Updates the status of a data source permission request. + * + * @param int $permission_id The ID of the permission request. + * @param string $new_status The new status ('Approved', 'Rejected', 'Revoked'). + * @param string|null $notes Optional notes for the status change. + * @param int $mod_by The user ID of the Data Owner or DAC Staff who updated the status. + * @return bool True on success. + * @throws Exception If a database error occurs. + */ + public function updatePermissionStatus(int $permissionId, string $newStatus, int $modifyingUserId, string $notes = ''): bool { + $sql = "UPDATE dsps_tbl_datasource_permission + SET + dspsdsp_status = :newStatus, + dspsdsp_mod_datetime = NOW(), + dspsdsp_reg_by = :modifyingUserId, + dspsdsp_notes = :notes + WHERE pkdspsdsp_id = :permissionId"; + + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':newStatus', $newStatus); + $stmt->bindParam(':modifyingUserId', $modifyingUserId, PDO::PARAM_INT); + $stmt->bindParam(':notes', $notes); + $stmt->bindParam(':permissionId', $permissionId, PDO::PARAM_INT); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error updating permission status: " . $e->getMessage()); + return false; + } + } + + /** + * Checks if a user has a specific permission for a data source. + * + * @param int $user_person_id The person ID of the user. + * @param int $data_source_id The ID of the data source. + * @param string $permission_type The type of permission to check ('Read', 'Download', 'Analyze'). + * @return bool True if the user has the permission, false otherwise. + * @throws Exception If a database error occurs. + */ + public function hasPermission(int $user_person_id, int $data_source_id, string $permission_type): bool { + $sql = "SELECT COUNT(*) FROM dsps_tbl_datasource_permission + WHERE fkisp_id_of = :user_person_id + AND fkdspsds_id = :data_source_id + AND dspsdsp_permission = :permission_type + AND dspsdsp_status = 'Approved'"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':user_person_id', $user_person_id, PDO::PARAM_INT); + $stmt->bindParam(':data_source_id', $data_source_id, PDO::PARAM_INT); + $stmt->bindParam(':permission_type', $permission_type); + $stmt->execute(); + return $stmt->fetchColumn() > 0; + } catch (PDOException $e) { + error_log("Error checking permission for user ($user_person_id) on data source ($data_source_id): " . $e->getMessage()); + throw new Exception("Could not verify permission. Please try again later."); + } + } + + /** + * Returns usage counts grouped by consuming user for data sources owned by a person. + * + * @param int $ownerPersonId + * @param int $limit + * @return array> + */ + public function getUsageByUserForOwner(int $ownerPersonId, int $limit = 10): array { + $sql = "SELECT u.isu_name AS username, + COUNT(dsu.pkdspsdspused_id) AS usage_count + FROM dsps_tbl_datasource_used dsu + JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id + JOIN ist_tbl_users u ON dsu.dspsdspused_reg_by = u.pkisu_id + WHERE ds.fkisp_id_of = :ownerPersonId + GROUP BY u.pkisu_id, u.isu_name + ORDER BY usage_count DESC + LIMIT :limit"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue(':ownerPersonId', $ownerPersonId, PDO::PARAM_INT); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching usage by user for owner ($ownerPersonId): " . $e->getMessage()); + return []; + } + } + + /** + * Retrieves usage counts for a specific user grouped by data source. + * + * @param int $userPersonId + * @param int $limit + * @return array> + */ + public function getUsageByDataSourceForUser(int $userPersonId, int $limit = 8): array { + $sql = "SELECT ds.pkdspsds_id, + ds.dspsds_title_en, + COUNT(dsu.pkdspsdspused_id) AS usage_count + FROM dsps_tbl_datasource_used dsu + JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id + WHERE dsu.dspsdspused_reg_by = :userPersonId + GROUP BY ds.pkdspsds_id, ds.dspsds_title_en + ORDER BY usage_count DESC + LIMIT :limit"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue(':userPersonId', $userPersonId, PDO::PARAM_INT); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching usage by data source for user ($userPersonId): " . $e->getMessage()); + return []; + } + } + + /** + * Retrieves all data sources with owner, type, and category details. + * + * @param string|null $search Optional search across title, owner, or type. + * @param string|null $status Optional status filter. + * @return array + */ + public function getAllDataSourcesDetailed(?string $search = null, ?string $status = null): array { + $sql = "SELECT ds.*, + dspstds.dspstds_name_en AS data_type_name, + dspscate.dspscate_title_en AS category_name, + CONCAT(p.isp_firstname_en, ' ', p.isp_lastname_en) AS owner_name + FROM dsps_tbl_datasource ds + JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id + JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id + JOIN ist_tbl_people p ON ds.fkisp_id_of = p.pkisp_id"; + + $conditions = []; + $params = []; + + if ($status !== null && $status !== '') { + $conditions[] = "ds.dspsds_status = :status"; + $params[':status'] = $status; + } + + if ($search !== null && $search !== '') { + $conditions[] = "(ds.dspsds_title_en LIKE :search OR p.isp_firstname_en LIKE :search OR p.isp_lastname_en LIKE :search OR dspstds.dspstds_name_en LIKE :search)"; + $params[':search'] = '%' . $search . '%'; + } + + if (!empty($conditions)) { + $sql .= " WHERE " . implode(' AND ', $conditions); + } + + $sql .= " ORDER BY ds.dspsds_reg_datetime DESC"; + + try { + $stmt = $this->pdo->prepare($sql); + foreach ($params as $key => $value) { + $stmt->bindValue($key, $value); + } + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching all data sources: " . $e->getMessage()); + return []; + } + } + + /** + * Updates the status of a data source. + * + * @param int $datasourceId + * @param string $newStatus + * @param int $modifyingUserId + * @return bool + */ + public function updateDataSourceStatus(int $datasourceId, string $newStatus, int $modifyingUserId): bool { + $allowed = ['Active', 'Inactive', 'Pending Review', 'Published']; + if (!in_array($newStatus, $allowed, true)) { + throw new InvalidArgumentException("Invalid data source status value."); + } + + $sql = "UPDATE dsps_tbl_datasource + SET dspsds_status = :status, + dspsds_mod_datetime = CURRENT_TIMESTAMP, + dspsds_reg_by = :modBy + WHERE pkdspsds_id = :id"; + + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':status', $newStatus); + $stmt->bindValue(':modBy', $modifyingUserId, PDO::PARAM_INT); + $stmt->bindValue(':id', $datasourceId, PDO::PARAM_INT); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error updating data source status (ID: $datasourceId): " . $e->getMessage()); + return false; + } + } + + /** + * Retrieves all permissions for a specific user. + * + * @param int $user_person_id The person ID of the user. + * @param string|null $status Optional: Filter by status ('Pending', 'Approved', 'Rejected', 'Revoked'). + * @return array An array of permission data. + * @throws Exception If a database error occurs. + */ + public function getUserPermissions(int $user_person_id, ?string $status = null): array { + $sql = "SELECT dsp.*, ds.dspsds_title_en, ds.dspsds_description, ds.dspsds_filename, + ds.dspsds_filename1, ds.dspsds_filename2, ds.dspsds_filename3, + dspstds.dspstds_name_en AS data_type_name, dspscate.dspscate_title_en AS category_name + FROM dsps_tbl_datasource_permission dsp + JOIN dsps_tbl_datasource ds ON dsp.fkdspsds_id = ds.pkdspsds_id + JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id + JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id + WHERE dsp.fkisp_id_of = :user_person_id"; + $params = [':user_person_id' => $user_person_id]; + + if ($status) { + $sql .= " AND dsp.dspsdsp_status = :status"; + $params[':status'] = $status; + } + + $sql .= " ORDER BY dsp.dspsdsp_reg_datetime DESC"; + + try { + $stmt = $this->pdo->prepare($sql); + foreach ($params as $key => &$val) { + $stmt->bindParam($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR); + } + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching user permissions ($user_person_id): " . $e->getMessage()); + throw new Exception("Could not retrieve your permissions. Please try again later."); + } + } + + /** + * Retrieves all approved data sources a user is permitted to analyse/download. + * + * @param int $userPersonId The person ID tied to the current session. + * @return array + * @throws Exception + */ + public function getApprovedDataSourcesForUser(int $userPersonId): array { + return $this->getUserPermissions($userPersonId, 'Approved'); + } + + /** + * Synchronises the approved data sources for a user into a dedicated Jupyter workspace directory. + * Files are symlinked when possible to avoid duplication; if symlinks are not permitted, files are copied. + * + * @param int $userPersonId The person ID of the user. + * @param string $workspaceRoot Absolute path to the shared Jupyter workspace root. + * @return array{synced: array>, missing: array>, workspace_dir: string} + * @throws Exception When the workspace cannot be prepared. + */ + public function prepareJupyterWorkspace(int $userPersonId, string $workspaceRoot): array { + if (!is_dir($workspaceRoot) && !mkdir($workspaceRoot, 0775, true) && !is_dir($workspaceRoot)) { + throw new RuntimeException("Unable to create Jupyter workspace root at {$workspaceRoot}"); + } + + $userFolderName = 'user_' . $userPersonId; + $userWorkspaceDir = rtrim($workspaceRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $userFolderName; + + if (!is_dir($userWorkspaceDir) && !mkdir($userWorkspaceDir, 0775, true) && !is_dir($userWorkspaceDir)) { + throw new RuntimeException("Unable to create user workspace directory at {$userWorkspaceDir}"); + } + + $this->purgeSyncedArtifacts($userWorkspaceDir); + + $approvedSources = $this->getApprovedDataSourcesForUser($userPersonId); + $synced = []; + $missing = []; + + foreach ($approvedSources as $index => $permission) { + $sourceId = (int)($permission['fkdspsds_id'] ?? 0); + $title = $permission['dspsds_title_en'] ?? ('Data Source ' . $sourceId); + $fileColumns = ['dspsds_filename', 'dspsds_filename1', 'dspsds_filename2', 'dspsds_filename3']; + $fileQueue = []; + + foreach ($fileColumns as $column) { + $candidate = trim((string)($permission[$column] ?? '')); + if ($candidate !== '' && !in_array($candidate, $fileQueue, true)) { + $fileQueue[] = $candidate; + } + } + + if (empty($fileQueue)) { + $missing[] = [ + 'datasource_id' => $sourceId, + 'title' => $title, + 'reason' => 'No files associated with this data source.', + ]; + continue; + } + + foreach ($fileQueue as $fileIndex => $filename) { + $sourcePath = $this->uploadDir . $filename; + if (!is_file($sourcePath)) { + $missing[] = [ + 'datasource_id' => $sourceId, + 'title' => $title, + 'reason' => 'File not found on disk: ' . $filename, + ]; + continue; + } + + $extension = strtolower((string) pathinfo($sourcePath, PATHINFO_EXTENSION)); + + if ($extension === 'ipynb') { + $missing[] = [ + 'datasource_id' => $sourceId, + 'title' => $title, + 'reason' => 'Notebook files (.ipynb) are personal workspaces and are not shared via sync.', + ]; + continue; + } + + $safeTitle = $this->sanitizeFileName($title); + $targetBase = sprintf('%03d_%s', $index + 1, $safeTitle ?: ('datasource_' . $sourceId)); + if ($fileIndex > 0) { + $targetBase .= '_file' . ($fileIndex + 1); + } + $targetName = $extension ? "{$targetBase}.{$extension}" : $targetBase; + $targetPath = $userWorkspaceDir . DIRECTORY_SEPARATOR . $targetName; + + if (file_exists($targetPath) || is_link($targetPath)) { + unlink($targetPath); + } + + $linked = @symlink($sourcePath, $targetPath); + if (!$linked) { + $linked = @copy($sourcePath, $targetPath); + } + + if ($linked) { + $synced[] = [ + 'datasource_id' => $sourceId, + 'title' => $title, + 'source_filename' => $filename, + 'relative_path' => $userFolderName . '/' . $targetName, + 'absolute_path' => $targetPath, + 'data_type' => $permission['data_type_name'] ?? null, + 'category' => $permission['category_name'] ?? null, + ]; + } else { + $missing[] = [ + 'datasource_id' => $sourceId, + 'title' => $title, + 'reason' => 'Unable to sync file into workspace: ' . $filename, + ]; + } + } + } + + $readmePath = $userWorkspaceDir . DIRECTORY_SEPARATOR . 'README.txt'; + $readmeBody = "Approved data sources for R in JupyterHub are synced to this folder.\n" + . "Each file is a symlink (or copy) of the original upload.\n" + . "Only data sources with approved permissions for your account appear here.\n" + . "Synced on: " . date('c') . "\n"; + file_put_contents($readmePath, $readmeBody); + + $manifestPath = $userWorkspaceDir . DIRECTORY_SEPARATOR . 'manifest.json'; + file_put_contents($manifestPath, json_encode([ + 'generated_at' => date('c'), + 'synced' => $synced, + 'missing' => $missing, + ], JSON_PRETTY_PRINT)); + + return [ + 'synced' => $synced, + 'missing' => $missing, + 'workspace_dir' => $userWorkspaceDir, + ]; + } + + /** + * Removes previously synced artefacts without touching user-authored notebooks. + * + * @param string $userWorkspaceDir + * @return void + */ + private function purgeSyncedArtifacts(string $userWorkspaceDir): void { + $manifestPath = $userWorkspaceDir . DIRECTORY_SEPARATOR . 'manifest.json'; + + if (is_readable($manifestPath)) { + $manifest = json_decode((string) file_get_contents($manifestPath), true); + if (isset($manifest['synced']) && is_array($manifest['synced'])) { + foreach ($manifest['synced'] as $syncedItem) { + if (empty($syncedItem['relative_path'])) { + continue; + } + + $basename = basename($syncedItem['relative_path']); + if ($basename === '' || $basename === '.' || $basename === '..') { + continue; + } + + $targetPath = $userWorkspaceDir . DIRECTORY_SEPARATOR . $basename; + if (is_file($targetPath) || is_link($targetPath)) { + @unlink($targetPath); + } + } + } + } + + foreach (['README.txt', 'manifest.json'] as $generatedFile) { + $filePath = $userWorkspaceDir . DIRECTORY_SEPARATOR . $generatedFile; + if (is_file($filePath)) { + @unlink($filePath); + } + } + + // Legacy clean-up: remove any auto-synced notebooks that may still exist from older runs. + // The sync process previously generated notebooks using a ###_ prefix; strip those out so + // personal notebooks authored by the user (custom names) remain untouched. + $iterator = new FilesystemIterator($userWorkspaceDir, FilesystemIterator::SKIP_DOTS); + foreach ($iterator as $item) { + if ($item->isFile() && preg_match('/^\d{3}_.+\.ipynb$/i', $item->getFilename())) { + @unlink($item->getPathname()); + } + } + } + + /** + * Removes all files and folders within a directory without deleting the directory itself. + * + * @param string $directory + * @return void + */ + private function clearDirectory(string $directory): void { + if (!is_dir($directory)) { + return; + } + + $iterator = new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS); + foreach ($iterator as $item) { + if ($item->isDir()) { + $this->deleteDirectory($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + } + + /** + * Recursively deletes a directory. + * + * @param string $path + * @return void + */ + private function deleteDirectory(string $path): void { + if (!is_dir($path)) { + return; + } + + $items = new FilesystemIterator($path, FilesystemIterator::SKIP_DOTS); + foreach ($items as $item) { + if ($item->isDir()) { + $this->deleteDirectory($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + rmdir($path); + } + + /** + * Sanitises a string for safe use as a file name. + * + * @param string $name + * @return string + */ + private function sanitizeFileName(string $name): string { + $sanitised = preg_replace('/[^a-zA-Z0-9-_]+/', '_', $name); + return trim($sanitised, '_'); + } + + // --- Usage Logging (dsps_tbl_anonymous, dsps_tbl_datasource_used) --- + + /** + * Logs an anonymous view of a data source introduction. + * + * @param int $fkdspsds_id The ID of the data source viewed. + * @param string|null $client_ip The IP address of the viewer. + * @param string $action The action performed (e.g., 'View Introduction'). + * @return bool True on success. + * @throws Exception If a database error occurs. + */ + public function logAnonymousView(int $fkdspsds_id, ?string $client_ip, string $action): bool { + $sql = "INSERT INTO dsps_tbl_anonymous (fkdspsds_id, dspsano_client_ip, dspsano_action) + VALUES (:fkdspsds_id, :client_ip, :action)"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':fkdspsds_id', $fkdspsds_id, PDO::PARAM_INT); + $stmt->bindParam(':client_ip', $client_ip); + $stmt->bindParam(':action', $action); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error logging anonymous view for data source ($fkdspsds_id): " . $e->getMessage()); + throw new Exception("Could not log anonymous view."); + } + } + + /** + * Logs a registered user's usage of a data source. + * + * @param int $fkdspsdsused_id The ID of the data source used. + * @param int $fkisp_id_of The person ID of the user who used it. + * @param string $action The action performed (e.g., 'Downloaded', 'Accessed API', 'Ran Analysis'). + * @param int $reg_by The user ID who performed the action. + * @return bool True on success. + * @throws Exception If a database error occurs. + */ + public function logDataSourceUsage(int $fkdspsdsused_id, int $fkisp_id_of, string $action, int $reg_by): bool { + $sql = "INSERT INTO dsps_tbl_datasource_used (fkdspsdsused_id, fkisp_id_of, dspsdspused_action, dspsdspused_reg_by) + VALUES (:fkdspsdsused_id, :fkisp_id_of, :action, :reg_by)"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':fkdspsdsused_id', $fkdspsdsused_id, PDO::PARAM_INT); + $stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT); + $stmt->bindParam(':action', $action); + $stmt->bindParam(':reg_by', $reg_by, PDO::PARAM_INT); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error logging data source usage for user ($fkisp_id_of) on data source ($fkdspsdsused_id): " . $e->getMessage()); + throw new Exception("Could not log data source usage."); + } + } + + /** + * Retrieves usage logs for a specific data source. + * + * @param int $data_source_id The ID of the data source. + * @param string|null $action Optional: Filter by action. + * @return array An array of usage log data. + * @throws Exception If a database error occurs. + */ + public function getDataSourceUsageLogs(int $data_source_id, ?string $action = null): array { + $sql = "SELECT dsu.*, p.isp_firstname_en, p.isp_lastname_en + FROM dsps_tbl_datasource_used dsu + JOIN ist_tbl_people p ON dsu.fkisp_id_of = p.pkisp_id + WHERE dsu.fkdspsdsused_id = :data_source_id"; + $params = [':data_source_id' => $data_source_id]; + + if ($action) { + $sql .= " AND dsu.dspsdspused_action = :action"; + $params[':action'] = $action; + } + + $sql .= " ORDER BY dsu.dspsdspused_reg_datetime DESC"; + + try { + $stmt = $this->pdo->prepare($sql); + foreach ($params as $key => &$val) { + $stmt->bindParam($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR); + } + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching data source usage logs for ($data_source_id): " . $e->getMessage()); + throw new Exception("Could not retrieve usage logs. Please try again later."); + } + } + + /** + * Retrieves a user's download history. + * + * @param int $user_person_id The person ID of the user. + * @return array An array of downloaded data sources. + * @throws Exception If a database error occurs. + */ + public function getUserDownloads(int $user_person_id): array { + $sql = "SELECT dsu.*, ds.dspsds_title_en, ds.dspsds_filename, + dspstds.dspstds_name_en AS data_type_name, dspscate.dspscate_title_en AS category_name + FROM dsps_tbl_datasource_used dsu + JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id + JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id + JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id + WHERE dsu.fkisp_id_of = :user_person_id AND dsu.dspsdspused_action = 'Downloaded' + ORDER BY dsu.dspsdspused_reg_datetime DESC"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':user_person_id', $user_person_id, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching user downloads for ($user_person_id): " . $e->getMessage()); + throw new Exception("Could not retrieve your downloads. Please try again later."); + } + } + + // --- Classification Methods (Moved from Classifications.php for simplicity, or keep separate) --- + // Assuming these methods are here based on previous discussions. + // If you have a separate Classifications.php, ensure these are in that file. + + /** + * Retrieves all data types. + * @return array + * @throws Exception If a database error occurs. + */ + public function getAllDataTypes(): array { + $sql = "SELECT * FROM dsps_tbl_typedatasource ORDER BY dspstds_name_en ASC"; + try { + $stmt = $this->pdo->query($sql); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching all data types: " . $e->getMessage()); + throw new Exception("Could not retrieve data types. Please try again later."); + } + } + public function getDataTypeById(int $typeId): ?array { + $sql = "SELECT * FROM dsps_tbl_typedatasource WHERE pkdspstds_id = :type_id"; + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':type_id', $typeId, PDO::PARAM_INT); + $stmt->execute(); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + return $result ?: null; + } + + /** + * Retrieves all data categories. + * @return array + * @throws Exception If a database error occurs. + */ + public function getAllCategories(): array { + $sql = "SELECT * FROM dsps_tbl_dspscategory ORDER BY dspscate_title_en ASC"; + try { + $stmt = $this->pdo->query($sql); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching all categories: " . $e->getMessage()); + throw new Exception("Could not retrieve categories. Please try again later."); + } + } + + /** + * Get a list of data sources, optionally filtered by category and search query. + * @param int|null $categoryId The ID of the category to filter by. + * @param string|null $searchQuery The search query to filter by title or description. + * @return array An array of data source records. + */ + public function getFilteredDataSources($categoryId = null, $searchQuery = null) + { + try { + // Use LEFT JOIN to get data from related tables. + // All table and column names are now verified against the provided SQL dump. + $sql = " + SELECT + ds.*, + COALESCE(cat.dspscate_title_en, 'Not specified') AS category_name, + COALESCE(dt.dspstds_name_en, 'Not specified') AS data_type_name, + COALESCE(p.isp_firstname_en, 'Not specified') AS isp_firstname_en, + COALESCE(p.isp_lastname_en, 'Not specified') AS isp_lastname_en + FROM dsps_tbl_datasource AS ds + LEFT JOIN dsps_tbl_dspscategory AS cat ON ds.fkdspscate_id = cat.pkdspscate_id + LEFT JOIN dsps_tbl_typedatasource AS dt ON ds.fkdspstds_id = dt.pkdspstds_id + LEFT JOIN ist_tbl_people AS p ON ds.fkisp_id_of = p.pkisp_id + WHERE ds.dspsds_status = 'Active' + "; + + $params = []; + $whereClauses = []; + + if ($categoryId) { + $whereClauses[] = "ds.fkdspscate_id = :category_id"; + $params[':category_id'] = $categoryId; + } + + if ($searchQuery) { + $search = "%" . $searchQuery . "%"; + $whereClauses[] = "(ds.dspsds_title_en LIKE :search OR ds.dspsds_description LIKE :search)"; + $params[':search'] = $search; + } + + if (!empty($whereClauses)) { + $sql .= " AND " . implode(" AND ", $whereClauses); + } + + // Order by title for a consistent display + $sql .= " ORDER BY ds.dspsds_title_en ASC"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + + } catch (PDOException $e) { + // Log the error and return an empty array to prevent further issues + error_log("Database error in getFilteredDataSources: " . $e->getMessage()); + return []; + } + } + + private function tableColumnExists(string $table, string $column): bool { + $cacheKey = $table . '.' . $column; + if (array_key_exists($cacheKey, $this->columnExistenceCache)) { + return $this->columnExistenceCache[$cacheKey]; + } + + if (!preg_match('/^[a-zA-Z0-9_]+$/', $table)) { + return false; + } + + $sql = sprintf('SHOW COLUMNS FROM `%s` LIKE :column', $table); + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':column', $column, PDO::PARAM_STR); + $stmt->execute(); + $exists = (bool) $stmt->fetch(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log('Error checking column existence: ' . $e->getMessage()); + // Assume column exists if we cannot verify to avoid silently skipping new features. + $exists = true; + } + + $this->columnExistenceCache[$cacheKey] = $exists; + return $exists; + } + + private function ensurePermissionProofColumn(): bool { + $table = 'dsps_tbl_datasource_permission'; + $column = 'dspsdsp_proof_path'; + $cacheKey = $table . '.' . $column; + + if ($this->tableColumnExists($table, $column)) { + return true; + } + + $alterSql = "ALTER TABLE `{$table}` ADD COLUMN `{$column}` VARCHAR(255) DEFAULT NULL AFTER dspsdsp_notes"; + try { + $this->pdo->exec($alterSql); + $this->columnExistenceCache[$cacheKey] = true; + return true; + } catch (PDOException $e) { + error_log('Failed to add proof column: ' . $e->getMessage()); + return false; + } + } +} diff --git a/classes/Faq.php b/classes/Faq.php new file mode 100644 index 0000000..3c1c2f6 --- /dev/null +++ b/classes/Faq.php @@ -0,0 +1,138 @@ +pdo = $pdo; + } + + /** + * Adds a new FAQ entry to the database. + * + * @param string $title_en The English question for the FAQ. + * @param string $description The English answer for the FAQ. + * @param int $reg_by The ID of the user who registered this entry (from ist_tbl_users). + * @param int $fkisp_id_of The ID of the person associated with this entry (from ist_tbl_people). + * @return bool True on success. + * @throws Exception If a database error occurs. + */ + public function addFaq(string $title_en, string $description, int $reg_by, int $fkisp_id_of): bool { + $sql = "INSERT INTO dsps_tbl_dspsfaq (dspsfaq_title_en, dspsfaq_description, dspsfaq_reg_by, fkisp_id_of) + VALUES (:title_en, :description, :reg_by, :fkisp_id_of)"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':title_en', $title_en); + $stmt->bindParam(':description', $description); + $stmt->bindParam(':reg_by', $reg_by); + $stmt->bindParam(':fkisp_id_of', $fkisp_id_of); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error adding FAQ entry: " . $e->getMessage()); + throw new Exception("Could not add FAQ entry. Please try again later."); + } + } + + /** + * Updates an existing FAQ entry. + * + * @param int $id The ID of the FAQ entry to update. + * @param string $title_en The new English question. + * @param string $description The new English answer. + * @param int $mod_by The ID of the user who modified this entry. + * @param int $fkisp_id_of The ID of the person associated with this entry. + * @return bool True on success. + * @throws Exception If a database error occurs. + */ + public function updateFaq(int $id, string $title_en, string $description, int $mod_by, int $fkisp_id_of): bool { + $sql = "UPDATE dsps_tbl_dspsfaq + SET dspsfaq_title_en = :title_en, dspsfaq_description = :description, + dspsfaq_mod_datetime = CURRENT_TIMESTAMP, dspsfaq_reg_by = :mod_by, fkisp_id_of = :fkisp_id_of + WHERE pkdspsfaq_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':title_en', $title_en); + $stmt->bindParam(':description', $description); + $stmt->bindParam(':mod_by', $mod_by); + $stmt->bindParam(':fkisp_id_of', $fkisp_id_of); + $stmt->bindParam(':id', $id); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error updating FAQ entry (ID: $id): " . $e->getMessage()); + throw new Exception("Could not update FAQ entry. Please try again later."); + } + } + + /** + * Deletes an FAQ entry. + * + * @param int $id The ID of the FAQ entry to delete. + * @return bool True on success. + * @throws Exception If a database error occurs. + */ + public function deleteFaq(int $id): bool { + $sql = "DELETE FROM dsps_tbl_dspsfaq WHERE pkdspsfaq_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':id', $id); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error deleting FAQ entry (ID: $id): " . $e->getMessage()); + throw new Exception("Could not delete FAQ entry. Please try again later."); + } + } + + /** + * Retrieves a single FAQ entry by its ID. + * + * @param int $id The ID of the FAQ entry. + * @return array|false The entry data as an associative array, or false if not found. + * @throws Exception If a database error occurs. + */ + public function getFaqById(int $id) { + $sql = "SELECT * FROM dsps_tbl_dspsfaq WHERE pkdspsfaq_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':id', $id); + $stmt->execute(); + return $stmt->fetch(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching FAQ entry by ID ($id): " . $e->getMessage()); + throw new Exception("Could not retrieve FAQ entry. Please try again later."); + } + } + + /** + * Retrieves all FAQ entries. + * + * @return array An array of FAQ entry data. + * @throws Exception If a database error occurs. + */ + public function getAllFaqs(): array { + $sql = "SELECT * FROM dsps_tbl_dspsfaq ORDER BY dspsfaq_reg_datetime ASC"; + try { + $stmt = $this->pdo->query($sql); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching all FAQ entries: " . $e->getMessage()); + throw new Exception("Could not retrieve FAQ entries. Please try again later."); + } + } + + /** + * Gets the total count of FAQ entries. + * + * @return int The total number of FAQ entries. + * @throws Exception If a database error occurs. + */ + public function getTotalFaqs(): int { + $sql = "SELECT COUNT(*) FROM dsps_tbl_dspsfaq"; + try { + $stmt = $this->pdo->query($sql); + return $stmt->fetchColumn(); + } catch (PDOException $e) { + error_log("Error getting total FAQ count: " . $e->getMessage()); + throw new Exception("Could not retrieve FAQ count. Please try again later."); + } + } +} diff --git a/classes/OAuth.php b/classes/OAuth.php new file mode 100644 index 0000000..2638432 --- /dev/null +++ b/classes/OAuth.php @@ -0,0 +1,257 @@ + JupyterHub integration. + */ +class OAuthService +{ + private const AUTH_CODE_TTL = 600; // 10 minutes + private const ACCESS_TOKEN_TTL = 3600; // 1 hour + private const REFRESH_TOKEN_TTL = 2592000; // 30 days + + private PDO $pdo; + + public function __construct(PDO $pdo) + { + $this->pdo = $pdo; + } + + public function getClient(string $clientId): ?array + { + $sql = "SELECT client_id, client_name, client_secret_hash, redirect_uris, allowed_scopes, is_confidential + FROM dsp_oauth_clients + WHERE client_id = :client_id AND is_revoked = 0"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute([':client_id' => $clientId]); + $client = $stmt->fetch(PDO::FETCH_ASSOC); + + return $client ?: null; + } + + public function verifyClientSecret(array $client, string $candidate): bool + { + if (empty($client['client_secret_hash'])) { + return $candidate === ''; + } + + return password_verify($candidate, $client['client_secret_hash']); + } + + public function isRedirectUriAllowed(array $client, string $redirectUri): bool + { + $allowed = array_filter(array_map('trim', preg_split('/[\s,]+/', (string) ($client['redirect_uris'] ?? '')))); + if (empty($allowed)) { + return false; + } + + foreach ($allowed as $prefix) { + if (stripos($redirectUri, $prefix) === 0) { + return true; + } + } + + return false; + } + + public function isScopeAllowed(array $client, ?string $requestedScope): bool + { + $requestedScope = trim((string) $requestedScope); + if ($requestedScope === '') { + return true; + } + + $allowedScopes = array_filter(array_map('trim', explode(' ', (string) ($client['allowed_scopes'] ?? '')))); + + if (empty($allowedScopes)) { + return true; + } + + foreach (explode(' ', $requestedScope) as $scope) { + if (!in_array($scope, $allowedScopes, true)) { + return false; + } + } + + return true; + } + + public function issueAuthorizationCode(string $clientId, int $personId, string $redirectUri, ?string $scope = null): array + { + $code = $this->generateToken(32); + $codeHash = $this->hashToken($code); + $expiresAt = time() + self::AUTH_CODE_TTL; + + $sql = "INSERT INTO dsp_oauth_auth_codes + (code_hash, client_id, person_id, scope, redirect_uri, expires_at, created_at) + VALUES (:code_hash, :client_id, :person_id, :scope, :redirect_uri, FROM_UNIXTIME(:expires_at), NOW())"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute([ + ':code_hash' => $codeHash, + ':client_id' => $clientId, + ':person_id' => $personId, + ':scope' => $scope, + ':redirect_uri' => $redirectUri, + ':expires_at' => $expiresAt, + ]); + + return [ + 'code' => $code, + 'expires_at' => $expiresAt, + ]; + } + + public function consumeAuthorizationCode(string $code, string $clientId): ?array + { + $codeHash = $this->hashToken($code); + + $sql = "SELECT code_hash, client_id, person_id, scope, redirect_uri, UNIX_TIMESTAMP(expires_at) AS expires_at + FROM dsp_oauth_auth_codes + WHERE code_hash = :code_hash"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute([':code_hash' => $codeHash]); + $record = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$record) { + return null; + } + + // Delete regardless of outcome + $deleteStmt = $this->pdo->prepare("DELETE FROM dsp_oauth_auth_codes WHERE code_hash = :code_hash"); + $deleteStmt->execute([':code_hash' => $codeHash]); + + if ((int) $record['expires_at'] < time()) { + return null; + } + + if ($record['client_id'] !== $clientId) { + return null; + } + + return $record; + } + + public function issueTokens(string $clientId, int $personId, ?string $scope = null, bool $includeRefresh = true): array + { + $accessToken = $this->generateToken(43); + $accessHash = $this->hashToken($accessToken); + $accessExpiresAt = time() + self::ACCESS_TOKEN_TTL; + + $refreshToken = null; + $refreshHash = null; + $refreshExpiresAt = null; + + if ($includeRefresh) { + $refreshToken = $this->generateToken(43); + $refreshHash = $this->hashToken($refreshToken); + $refreshExpiresAt = time() + self::REFRESH_TOKEN_TTL; + } + + $sql = "INSERT INTO dsp_oauth_access_tokens + (token_hash, client_id, person_id, scope, expires_at, refresh_token_hash, refresh_expires_at, created_at) + VALUES (:token_hash, :client_id, :person_id, :scope, FROM_UNIXTIME(:expires_at), + :refresh_hash, " . ($refreshExpiresAt ? "FROM_UNIXTIME(:refresh_expires_at)" : "NULL") . ", NOW())"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute([ + ':token_hash' => $accessHash, + ':client_id' => $clientId, + ':person_id' => $personId, + ':scope' => $scope, + ':expires_at' => $accessExpiresAt, + ':refresh_hash' => $refreshHash, + ':refresh_expires_at' => $refreshExpiresAt, + ]); + + return [ + 'access_token' => $accessToken, + 'access_expires_at' => $accessExpiresAt, + 'refresh_token' => $refreshToken, + 'refresh_expires_at' => $refreshExpiresAt, + 'token_type' => 'Bearer', + 'scope' => $scope, + ]; + } + + public function exchangeRefreshToken(string $clientId, string $refreshToken): ?array + { + $refreshHash = $this->hashToken($refreshToken); + + $sql = "SELECT token_hash, client_id, person_id, scope, UNIX_TIMESTAMP(refresh_expires_at) AS refresh_expires_at + FROM dsp_oauth_access_tokens + WHERE refresh_token_hash = :refresh_hash AND is_revoked = 0"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute([':refresh_hash' => $refreshHash]); + $record = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$record) { + return null; + } + + if ($record['client_id'] !== $clientId) { + return null; + } + + if (!empty($record['refresh_expires_at']) && (int) $record['refresh_expires_at'] < time()) { + $this->revokeTokenByHash($record['token_hash']); + return null; + } + + // Revoke old access token + $this->revokeTokenByHash($record['token_hash']); + + // Issue new pair + return $this->issueTokens($clientId, (int) $record['person_id'], $record['scope'], true); + } + + public function getAccessToken(string $token): ?array + { + $hash = $this->hashToken($token); + $sql = "SELECT token_hash, client_id, person_id, scope, + UNIX_TIMESTAMP(expires_at) AS expires_at, + refresh_token_hash, + UNIX_TIMESTAMP(refresh_expires_at) AS refresh_expires_at + FROM dsp_oauth_access_tokens + WHERE token_hash = :hash AND is_revoked = 0"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute([':hash' => $hash]); + $record = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$record) { + return null; + } + + if ((int) $record['expires_at'] < time()) { + $this->revokeTokenByHash($record['token_hash']); + return null; + } + + return $record; + } + + public function revokeTokenByHash(string $hash): void + { + $sql = "UPDATE dsp_oauth_access_tokens SET is_revoked = 1, revoked_at = NOW() + WHERE token_hash = :hash"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute([':hash' => $hash]); + } + + public function recordTokenUsage(string $hash): void + { + $sql = "UPDATE dsp_oauth_access_tokens SET last_used_at = NOW() WHERE token_hash = :hash"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute([':hash' => $hash]); + } + + private function generateToken(int $length): string + { + return rtrim(strtr(base64_encode(random_bytes($length)), '+/', '-_'), '='); + } + + private function hashToken(string $token): string + { + return hash('sha256', $token); + } +} diff --git a/classes/Permission.php b/classes/Permission.php new file mode 100644 index 0000000..0381d12 --- /dev/null +++ b/classes/Permission.php @@ -0,0 +1,144 @@ +pdo = $pdo; + } + + /** + * Checks if a user has a specific permission for a data source. + * @param int $personId The person's ID (fkisp_id). + * @param int $dataSourceId The data source ID (pkdspsds_id). + * @param string $permissionType The type of permission (e.g., 'Read', 'Download'). + * @return bool True if the permission is granted, false otherwise. + */ + public function hasPermission($personId, $dataSourceId, $permissionType) { + $sql = "SELECT COUNT(*) FROM dsps_tbl_datasource_permission + WHERE fkisp_id_of = ? AND fkdspsds_id = ? + AND dspsdsp_permission = ? AND dspsdsp_status = 'Approved'"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute([$personId, $dataSourceId, $permissionType]); + return $stmt->fetchColumn() > 0; + } + + /** + * Gets a pending request for a user and data source, if one exists. + * @param int $personId The person's ID (fkisp_id). + * @param int $dataSourceId The data source ID (pkdspsds_id). + * @param string $permissionType The type of permission. + * @return array|false The request data as an array, or false if not found. + */ + public function getPendingRequest($personId, $dataSourceId, $permissionType) { + $sql = "SELECT * FROM dsps_tbl_datasource_permission + WHERE fkisp_id_of = ? AND fkdspsds_id = ? + AND dspsdsp_permission = ? AND dspsdsp_status = 'Pending'"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute([$personId, $dataSourceId, $permissionType]); + return $stmt->fetch(PDO::FETCH_ASSOC); + } + + /** + * Adds a new permission request to the database. + * @param int $personId The person's ID (fkisp_id). + * @param int $dataSourceId The data source ID (pkdspsds_id). + * @param string $permissionType The type of permission requested. + * @param string $status The initial status of the request (e.g., 'Pending'). + * @param string $notes The user's justification for the request. + * @return bool True on success, false on failure. + */ + public function addPermissionRequest($personId, $dataSourceId, $permissionType, $status, $notes, ?string $proofPath = null) { + $hasProofColumn = $this->ensurePermissionProofColumn(); + + if ($hasProofColumn) { + $sql = "INSERT INTO dsps_tbl_datasource_permission (fkisp_id_of, fkdspsds_id, dspsdsp_permission, dspsdsp_notes, dspsdsp_proof_path, dspsdsp_status, dspsdsp_datetime) + VALUES (?, ?, ?, ?, ?, ?, NOW())"; + $params = [$personId, $dataSourceId, $permissionType, $notes, $proofPath, $status]; + } else { + $sql = "INSERT INTO dsps_tbl_datasource_permission (fkisp_id_of, fkdspsds_id, dspsdsp_permission, dspsdsp_notes, dspsdsp_status, dspsdsp_datetime) + VALUES (?, ?, ?, ?, ?, NOW())"; + $params = [$personId, $dataSourceId, $permissionType, $notes, $status]; + } + + $stmt = $this->pdo->prepare($sql); + return $stmt->execute($params); + } + + /** + * Gets all permission requests for a specific user. + * This method is needed for the 'my_permissions.php' script. + * @param int $personId The person's ID (fkisp_id). + * @return array An array of all permission requests for the given person. + */ + public function getPermissionsByPersonId($personId) { + $hasProofColumn = $this->ensurePermissionProofColumn(); + $proofSelect = $hasProofColumn + ? 'pr.dspsdsp_proof_path AS dspspr_proof_path' + : 'NULL AS dspspr_proof_path'; + + $sql = "SELECT + ds.dspsds_title_en AS ds_title, + pr.dspsdsp_permission AS dspspr_permission_type, + pr.dspsdsp_reg_datetime AS dspspr_request_date, + pr.dspsdsp_status AS dspspr_status, + pr.dspsdsp_notes AS dspspr_notes, + $proofSelect + FROM dsps_tbl_datasource_permission pr + JOIN dsps_tbl_datasource ds ON pr.fkdspsds_id = ds.pkdspsds_id + WHERE pr.fkisp_id_of = ?"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute([$personId]); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + private function tableColumnExists(string $table, string $column): bool { + $cacheKey = $table . '.' . $column; + if (array_key_exists($cacheKey, $this->columnExistenceCache)) { + return $this->columnExistenceCache[$cacheKey]; + } + + if (!preg_match('/^[a-zA-Z0-9_]+$/', $table)) { + return false; + } + + $sql = sprintf('SHOW COLUMNS FROM `%s` LIKE :column', $table); + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':column', $column, PDO::PARAM_STR); + $stmt->execute(); + $exists = (bool) $stmt->fetch(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log('Error checking column existence: ' . $e->getMessage()); + // Assume the column exists if we cannot verify (safer than silently skipping writes) + $exists = true; + } + + $this->columnExistenceCache[$cacheKey] = $exists; + return $exists; + } + + private function ensurePermissionProofColumn(): bool { + $table = 'dsps_tbl_datasource_permission'; + $column = 'dspsdsp_proof_path'; + $cacheKey = $table . '.' . $column; + + if ($this->tableColumnExists($table, $column)) { + return true; + } + + $alterSql = "ALTER TABLE `{$table}` ADD COLUMN `{$column}` VARCHAR(255) DEFAULT NULL AFTER dspsdsp_notes"; + try { + $this->pdo->exec($alterSql); + $this->columnExistenceCache[$cacheKey] = true; + return true; + } catch (PDOException $e) { + error_log('Failed to add proof column: ' . $e->getMessage()); + return false; + } + } +} diff --git a/classes/PermissionManager.php b/classes/PermissionManager.php new file mode 100644 index 0000000..de434c3 --- /dev/null +++ b/classes/PermissionManager.php @@ -0,0 +1,45 @@ +pdo = $pdo; + } + + /** + * Checks if a specific person has a specific permission for a data source. + * @param int $personId The ID of the person. + * @param int $dataSourceId The ID of the data source. + * @param string $permissionType The type of permission to check ('Read' or 'Download'). + * @return bool True if the permission exists, false otherwise. + */ + public function hasPermission($personId, $dataSourceId, $permissionType) + { + try { + // Using a prepared statement to prevent SQL injection + $sql = "SELECT COUNT(*) FROM dspsds_person_permissions + WHERE fk_dspsdspp_person_id = :personId + AND fk_dspsdspp_dspsds_id = :dataSourceId + AND dspsdspp_permission = :permissionType"; + + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':personId', $personId, PDO::PARAM_INT); + $stmt->bindParam(':dataSourceId', $dataSourceId, PDO::PARAM_INT); + $stmt->bindParam(':permissionType', $permissionType, PDO::PARAM_STR); + + $stmt->execute(); + + return $stmt->fetchColumn() > 0; + + } catch (PDOException $e) { + // Log the error but don't expose it to the user + error_log("Database error in hasPermission: " . $e->getMessage()); + return false; + } + } +} diff --git a/classes/Slide.php b/classes/Slide.php new file mode 100644 index 0000000..a446d8e --- /dev/null +++ b/classes/Slide.php @@ -0,0 +1,188 @@ +pdo = $pdo; + $this->uploadDir = __DIR__ . '/../uploads/slides/'; + // Ensure upload directory exists + if (!is_dir($this->uploadDir) && !mkdir($this->uploadDir, 0775, true) && !is_dir($this->uploadDir)) { + throw new RuntimeException('Unable to create slides upload directory.'); + } + } + + /** + * Adds a new slide to the database. + * + * @param string $title_en The English title of the slide. + * @param string $description The full description of the slide. + * @param string $photoname The filename of the uploaded photo. + * @param int $reg_by The ID of the user who registered the slide. + * @param int $fkisp_id_of The person ID of the user who registered the slide. + * @return bool True on success, false on failure. + * @throws Exception If a database error occurs. + */ + public function addSlide(string $title_en, string $description, string $photoname, int $reg_by, int $fkisp_id_of): bool { + $sql = "INSERT INTO dsps_tbl_dspsslide (dspsslide_title_en, dspsslide_description, dspsslide_photoname, dspsslide_reg_by, fkisp_id_of) + VALUES (:title_en, :description, :photoname, :reg_by, :fkisp_id_of)"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':title_en', $title_en); + $stmt->bindParam(':description', $description); + $stmt->bindParam(':photoname', $photoname); + $stmt->bindParam(':reg_by', $reg_by, PDO::PARAM_INT); + $stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error adding slide: " . $e->getMessage()); + throw new Exception("Could not add slide. Please try again later."); + } + } + + /** + * Updates an existing slide in the database. + * + * @param int $id The ID of the slide to update. + * @param string $title_en The new English title. + * @param string $description The new description. + * @param string $photoname The new filename of the photo. + * @param int $mod_by The ID of the user who modified the slide. + * @param int $fkisp_id_of The person ID of the user who modified the slide. + * @return bool True on success, false on failure. + * @throws Exception If a database error occurs. + */ + public function updateSlide(int $id, string $title_en, string $description, string $photoname, int $mod_by, int $fkisp_id_of): bool { + $sql = "UPDATE dsps_tbl_dspsslide + SET dspsslide_title_en = :title_en, dspsslide_description = :description, dspsslide_photoname = :photoname, + dspsslide_mod_datetime = CURRENT_TIMESTAMP, dspsslide_reg_by = :mod_by, fkisp_id_of = :fkisp_id_of + WHERE pkdspsslide_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':title_en', $title_en); + $stmt->bindParam(':description', $description); + $stmt->bindParam(':photoname', $photoname); + $stmt->bindParam(':mod_by', $mod_by, PDO::PARAM_INT); + $stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT); + $stmt->bindParam(':id', $id, PDO::PARAM_INT); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error updating slide (ID: $id): " . $e->getMessage()); + throw new Exception("Could not update slide. Please try again later."); + } + } + + /** + * Deletes a slide from the database and its associated photo file. + * + * @param int $id The ID of the slide to delete. + * @return bool True on success, false on failure. + * @throws Exception If a database error occurs. + */ + public function deleteSlide(int $id): bool { + // First, get the photo path to delete the file + $slide = $this->getSlideById($id); + if ($slide && !empty($slide['dspsslide_photoname'])) { + $filePath = $this->uploadDir . $slide['dspsslide_photoname']; + if (file_exists($filePath)) { + unlink($filePath); // Delete the file + } + } + + $sql = "DELETE FROM dsps_tbl_dspsslide WHERE pkdspsslide_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':id', $id, PDO::PARAM_INT); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error deleting slide (ID: $id): " . $e->getMessage()); + throw new Exception("Could not delete slide. Please try again later."); + } + } + + /** + * Retrieves a single slide by its ID. + * + * @param int $id The ID of the slide. + * @return array|false The slide data as an associative array, or false if not found. + * @throws Exception If a database error occurs. + */ + public function getSlideById(int $id) { + $sql = "SELECT * FROM dsps_tbl_dspsslide WHERE pkdspsslide_id = :id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':id', $id, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetch(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching slide by ID ($id): " . $e->getMessage()); + throw new Exception("Could not retrieve slide. Please try again later."); + } + } + + /** + * Retrieves all slides. + * + * @return array An array of slide data. + * @throws Exception If a database error occurs. + */ + public function getAllSlides(): array { + $sql = "SELECT * FROM dsps_tbl_dspsslide ORDER BY pkdspsslide_id ASC"; // Order by ID or a custom sort order + try { + $stmt = $this->pdo->query($sql); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching all slides: " . $e->getMessage()); + throw new Exception("Could not retrieve slides. Please try again later."); + } + } + + /** + * Gets the total count of slides. + * + * @return int The total number of slides. + * @throws Exception If a database error occurs. + */ + public function getTotalSlides(): int { + $sql = "SELECT COUNT(*) FROM dsps_tbl_dspsslide"; + try { + $stmt = $this->pdo->query($sql); + return $stmt->fetchColumn(); + } catch (PDOException $e) { + error_log("Error getting total slides count: " . $e->getMessage()); + throw new Exception("Could not retrieve slide count. Please try again later."); + } + } + + /** + * Handles the upload of a slide photo. + * + * @param array $file The $_FILES array for the uploaded photo. + * @return string The unique filename of the uploaded photo. + * @throws Exception If the upload fails or file type is invalid. + */ + public function handlePhotoUpload(array $file): string { + if ($file['error'] !== UPLOAD_ERR_OK) { + throw new Exception('File upload error: ' . $file['error']); + } + + $allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->file($file['tmp_name']); + + if (!in_array($mimeType, $allowedTypes)) { + throw new Exception('Invalid file type. Only JPEG, PNG, and GIF images are allowed.'); + } + + $extension = pathinfo($file['name'], PATHINFO_EXTENSION); + $uniqueFilename = uniqid('slide_') . '.' . $extension; + $destination = $this->uploadDir . $uniqueFilename; + + if (!move_uploaded_file($file['tmp_name'], $destination)) { + throw new Exception('Failed to move uploaded file.'); + } + + return $uniqueFilename; + } +} diff --git a/classes/User.php b/classes/User.php new file mode 100644 index 0000000..24366a9 --- /dev/null +++ b/classes/User.php @@ -0,0 +1,386 @@ +pdo = $pdo; + } + + /** + * Registers a new user and their personal information in the database. + * @param array $person_data An array of personal information. + * @param array $user_data An array of user account data. + * @return bool True on success, false on failure. + */ + public function registerUser($person_data, $user_data) + { + // Start a transaction to ensure both person and user data are saved or neither is + $this->pdo->beginTransaction(); + + try { + // Check for duplicate ID card, phone, or email before inserting + $dupConditions = []; + $dupParams = []; + $duplicateLabels = []; + if (!empty($person_data['id_card'])) { + $dupConditions[] = "isp_idcard = :id_card"; + $dupParams[':id_card'] = $person_data['id_card']; + $duplicateLabels[] = 'ID card'; + } + if (!empty($person_data['phone_number'])) { + $dupConditions[] = "isp_phone_number = :phone"; + $dupParams[':phone'] = $person_data['phone_number']; + $duplicateLabels[] = 'phone number'; + } + if (!empty($person_data['email'])) { + $dupConditions[] = "isp_email = :email"; + $dupParams[':email'] = $person_data['email']; + $duplicateLabels[] = 'email'; + } + + if (!empty($dupConditions)) { + $check_sql = "SELECT pkisp_id FROM ist_tbl_people WHERE " . implode(' OR ', $dupConditions); + $check_stmt = $this->pdo->prepare($check_sql); + $check_stmt->execute($dupParams); + + if ($check_stmt->fetch(PDO::FETCH_ASSOC)) { + $this->pdo->rollBack(); + $duplicateMessage = 'information'; + if (!empty($duplicateLabels)) { + if (count($duplicateLabels) === 1) { + $duplicateMessage = $duplicateLabels[0]; + } elseif (count($duplicateLabels) === 2) { + $duplicateMessage = implode(' or ', $duplicateLabels); + } else { + $last = array_pop($duplicateLabels); + $duplicateMessage = implode(', ', $duplicateLabels) . ", or {$last}"; + } + } + set_message("A user with this {$duplicateMessage} already exists.", "danger"); + return false; + } + } + + // Check for duplicate username + $check_username_sql = "SELECT pkisu_id FROM ist_tbl_users WHERE isu_name = :username"; + $check_username_stmt = $this->pdo->prepare($check_username_sql); + $check_username_stmt->execute([':username' => $user_data['username']]); + + if ($check_username_stmt->fetch(PDO::FETCH_ASSOC)) { + $this->pdo->rollBack(); + set_message("This username is already taken. Please choose another one.", "danger"); + return false; + } + + // 1. Insert into ist_tbl_people + $person_sql = " + INSERT INTO ist_tbl_people ( + isp_idcard, isp_firstname_en, isp_lastname_en, isp_sex, + isp_dob, isp_pob, isp_nationality, isp_marital_status, + isp_phone_number, isp_email, isp_telegram, isp_note + ) VALUES ( + :id_card, :first_name_en, :last_name_en, :sex, + :dob, :pob, :nationality, :marital_status, + :phone_number, :email, :telegram, :note + ) + "; + $person_stmt = $this->pdo->prepare($person_sql); + $person_stmt->execute([ + ':id_card' => ($person_data['id_card'] ?? '') !== '' ? $person_data['id_card'] : null, + ':first_name_en' => $person_data['first_name_en'], + ':last_name_en' => $person_data['last_name_en'], + ':sex' => $person_data['sex'], + ':dob' => $person_data['dob'], + ':pob' => $person_data['pob'], + ':nationality' => $person_data['nationality'], + ':marital_status' => $person_data['marital_status'], + ':phone_number' => $person_data['phone_number'], + ':email' => $person_data['email'], + ':telegram' => $person_data['telegram'], + ':note' => $person_data['note'] + ]); + + // Get the ID of the newly inserted person record + $person_id = $this->pdo->lastInsertId(); + + // 2. Insert into ist_tbl_users + $user_sql = " + INSERT INTO ist_tbl_users ( + fkisp_id_of, isu_name, isu_password, isu_status, isu_can_run_r + ) VALUES ( + :fkisp_id_of, :username, :password, :status, :can_run_r + ) + "; + + $user_stmt = $this->pdo->prepare($user_sql); + $user_stmt->execute([ + ':fkisp_id_of' => $person_id, + ':username' => $user_data['username'], + ':password' => password_hash($user_data['password'], PASSWORD_DEFAULT), // Hash the password + ':status' => $user_data['status'], + ':can_run_r' => empty($user_data['can_run_r']) ? 0 : 1 + ]); + + // Commit the transaction + $this->pdo->commit(); + return true; + + } catch (PDOException $e) { + // Roll back the transaction on any error + $this->pdo->rollBack(); + // Log the detailed error + error_log("Registration failed: " . $e->getMessage()); + set_message("Registration failed due to a database error. Please try again.", "danger"); + return false; + } + } + + /** + * Authenticates a user based on username and password. + * + * @param string $username The user's username. + * @param string $password The user's plain-text password. + * @return array|false User data (pkisu_id, fkisp_id_of, isu_name, isu_status) on success, false on failure. + * @throws Exception If a database error occurs. + */ + public function authenticateUser(string $username, string $password) { + $sql = "SELECT pkisu_id, fkisp_id_of, isu_name, isu_password, isu_status, isu_can_run_r + FROM ist_tbl_users + WHERE isu_name = :username AND isu_status != 'Inactive'"; // Do not allow login for inactive users + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':username', $username); + $stmt->execute(); + $user = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($user && password_verify($password, $user['isu_password'])) { + // Remove password hash before returning user data + unset($user['isu_password']); + return $user; + } + return false; + } catch (PDOException $e) { + error_log("Error authenticating user: " . $e->getMessage()); + throw new Exception("Authentication failed due to a server error. Please try again later."); + } + } + + /** + * Retrieves full user details by user ID (pkisu_id). + * + * @param int $user_id The pkisu_id of the user. + * @return array|false The combined user and person data, or false if not found. + * @throws Exception If a database error occurs. + */ + public function getUserDetails(int $user_id) { + $sql = "SELECT u.*, p.* + FROM ist_tbl_users u + JOIN ist_tbl_people p ON u.fkisp_id_of = p.pkisp_id + WHERE u.pkisu_id = :user_id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':user_id', $user_id, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetch(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching user details for ID ($user_id): " . $e->getMessage()); + throw new Exception("Could not retrieve user details. Please try again later."); + } + } + + /** + * Retrieves all users, optionally filtered by status and/or search query. + * + * @param string|null $search_query Optional search term for username, first name, last name, email, phone. + * @param string|null $status_filter Optional status to filter by (e.g., 'Data Owner'). + * @return array An array of user data. + * @throws Exception If a database error occurs. + */ + public function getAllUsers(?string $search_query = null, ?string $status_filter = null): array { + $sql = "SELECT u.pkisu_id, u.isu_name, u.isu_status, u.isu_reg_datetime, u.isu_mod_datetime, + u.isu_can_run_r, + p.isp_firstname_en, p.isp_lastname_en, p.isp_email, p.isp_phone_number + FROM ist_tbl_users u + JOIN ist_tbl_people p ON u.fkisp_id_of = p.pkisp_id"; + $conditions = []; + $params = []; + + if ($status_filter) { + $conditions[] = "u.isu_status = :status_filter"; + $params[':status_filter'] = $status_filter; + } + + if ($search_query) { + $search_term = '%' . $search_query . '%'; + $conditions[] = "(u.isu_name LIKE :search_query OR + p.isp_firstname_en LIKE :search_query OR + p.isp_lastname_en LIKE :search_query OR + p.isp_email LIKE :search_query OR + p.isp_phone_number LIKE :search_query)"; + $params[':search_query'] = $search_term; + } + + if (!empty($conditions)) { + $sql .= " WHERE " . implode(" AND ", $conditions); + } + + $sql .= " ORDER BY u.isu_reg_datetime DESC"; + + try { + $stmt = $this->pdo->prepare($sql); + foreach ($params as $key => &$val) { + // Use PDO::PARAM_STR for all search/filter parameters, as they are strings + $stmt->bindParam($key, $val, PDO::PARAM_STR); + } + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + error_log("Error fetching all users: " . $e->getMessage()); + throw new Exception("Could not retrieve user list. Please try again later."); + } + } + + /** + * Gets the total count of registered users. + * + * @return int The total number of users. + * @throws Exception If a database error occurs. + */ + public function getTotalUsers(): int { + $sql = "SELECT COUNT(*) FROM ist_tbl_users"; + try { + $stmt = $this->pdo->query($sql); + return $stmt->fetchColumn(); + } catch (PDOException $e) { + error_log("Error getting total users count: " . $e->getMessage()); + throw new Exception("Could not retrieve user count. Please try again later."); + } + } + + /** + * Updates a user's status (role). + * + * @param int $user_id The ID of the user to update. + * @param string $new_status The new status ('DAC Staff', 'Data Owner', 'Data User', 'Inactive'). + * @param int $mod_by The ID of the user performing the modification. + * @return bool True on success. + * @throws Exception If a database error occurs. + */ + public function updateUserStatus(int $user_id, string $new_status, int $mod_by): bool { + $sql = "UPDATE ist_tbl_users + SET isu_status = :new_status, isu_mod_datetime = CURRENT_TIMESTAMP, isu_regby_id = :mod_by + WHERE pkisu_id = :user_id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':new_status', $new_status); + $stmt->bindParam(':mod_by', $mod_by, PDO::PARAM_INT); + $stmt->bindParam(':user_id', $user_id, PDO::PARAM_INT); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error updating user status (ID: $user_id): " . $e->getMessage()); + throw new Exception("Could not update user status. Please try again later."); + } + } + + /** + * Grants or revokes R/Jupyter access for a user. + * + * @param int $user_id The pkisu_id of the user. + * @param bool $can_run_r Whether the user should have access. + * @param int $mod_by The ID of the admin performing the change. + * @return bool True on success. + * @throws Exception If a database error occurs. + */ + public function updateUserRJupyterAccess(int $user_id, bool $can_run_r, int $mod_by): bool { + $sql = "UPDATE ist_tbl_users + SET isu_can_run_r = :can_run_r, isu_mod_datetime = CURRENT_TIMESTAMP, isu_regby_id = :mod_by + WHERE pkisu_id = :user_id"; + try { + $stmt = $this->pdo->prepare($sql); + $flag = $can_run_r ? 1 : 0; + $stmt->bindParam(':can_run_r', $flag, PDO::PARAM_INT); + $stmt->bindParam(':mod_by', $mod_by, PDO::PARAM_INT); + $stmt->bindParam(':user_id', $user_id, PDO::PARAM_INT); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error updating R/Jupyter access (ID: $user_id): " . $e->getMessage()); + throw new Exception("Could not update R/Jupyter access. Please try again later."); + } + } + + /** + * Updates a user's personal information. + * + * @param int $person_id The pkisp_id of the person to update. + * @param array $person_data Associative array with fields to update (e.g., isp_firstname_en, isp_phone_number). + * @param int $mod_by The ID of the user performing the modification. + * @return bool True on success. + * @throws Exception If a database error occurs or duplicate entry. + */ + public function updatePersonInfo(int $person_id, array $person_data, int $mod_by): bool { + $setClauses = []; + $params = [':person_id' => $person_id, ':mod_by' => $mod_by]; + + foreach ($person_data as $key => $value) { + // Only allow specific fields to be updated + if (in_array($key, [ + 'isp_idcard', 'isp_firstname_en', 'isp_lastname_en', 'isp_sex', 'isp_dob', + 'isp_pob', 'isp_nationality', 'isp_marital_status', 'isp_phone_number', + 'isp_email', 'isp_telegram', 'isp_note' + ])) { + $setClauses[] = "$key = :$key"; + $params[":$key"] = ($value === '' ? null : $value); + } + } + + if (empty($setClauses)) { + return false; // No fields to update + } + + $sql = "UPDATE ist_tbl_people + SET " . implode(', ', $setClauses) . ", isp_mod_datetime = CURRENT_TIMESTAMP, isp_regby_id = :mod_by + WHERE pkisp_id = :person_id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + return $stmt->rowCount() > 0; + } catch (PDOException $e) { + if ($e->getCode() == '23000') { + throw new Exception("A duplicate entry was found for ID card, email, or phone number."); + } + error_log("Error updating person info (ID: $person_id): " . $e->getMessage()); + throw new Exception("Could not update personal information. Please try again later."); + } + } + + /** + * Changes a user's password. + * + * @param int $user_id The pkisu_id of the user. + * @param string $new_password The new plain-text password. + * @param int $mod_by The ID of the user performing the modification. + * @return bool True on success. + * @throws Exception If password hashing fails or database error. + */ + public function changePassword(int $user_id, string $new_password, int $mod_by): bool { + $hashed_password = password_hash($new_password, PASSWORD_DEFAULT); + if ($hashed_password === false) { + throw new Exception("Failed to hash new password."); + } + + $sql = "UPDATE ist_tbl_users + SET isu_password = :hashed_password, isu_mod_datetime = CURRENT_TIMESTAMP, isu_regby_id = :mod_by + WHERE pkisu_id = :user_id"; + try { + $stmt = $this->pdo->prepare($sql); + $stmt->bindParam(':hashed_password', $hashed_password); + $stmt->bindParam(':mod_by', $mod_by, PDO::PARAM_INT); + $stmt->bindParam(':user_id', $user_id, PDO::PARAM_INT); + return $stmt->execute(); + } catch (PDOException $e) { + error_log("Error changing password for user (ID: $user_id): " . $e->getMessage()); + throw new Exception("Could not change password. Please try again later."); + } + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..37452d3 --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "name": "niph/dsp", + "type": "project", + "description": "Data Sharing Platform (DSP) with JupyterHub integration", + "require": { + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "scripts": { + "test": "phpunit" + }, + "autoload": { + "classmap": [ + "classes/" + ], + "files": [ + "includes/jupyter_helpers.php", + "includes/auth.php" + ] + }, + "config": { + "sort-packages": true + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..7f06f40 --- /dev/null +++ b/composer.lock @@ -0,0 +1,1688 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "84f31793da49f1c6a5f28bf7e1143119", + "packages": [], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.6.2", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "3a454ca033b9e06b63282ce19562e892747449bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", + "reference": "3a454ca033b9e06b63282ce19562e892747449bb", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" + }, + "time": "2025-10-21T19:32:17+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "10.1.16", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:31:57+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.5.58", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "e24fb46da450d8e6a5788670513c1af1424f16ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e24fb46da450d8e6a5788670513c1af1424f16ca", + "reference": "e24fb46da450d8e6a5788670513c1af1424f16ca", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.4", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.58" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2025-09-28T12:04:46+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:12:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:43+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:59:15+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e8e53097718d2b53cfb2aa859b06a41abf58c62e", + "reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2025-09-07T05:25:07+00:00" + }, + { + "name": "sebastian/complexity", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68ff824baeae169ec9f2137158ee529584553799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:37:17+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:15:17+00:00" + }, + { + "name": "sebastian/environment", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-23T08:47:14+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "0735b90f4da94969541dac1da743446e276defa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:09:11+00:00" + }, + { + "name": "sebastian/global-state", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:19:19+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:38:20+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:50:56+00:00" + }, + { + "name": "sebastian/type", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:10:45+00:00" + }, + { + "name": "sebastian/version", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-07T11:34:05+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.2.3", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:36:25+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/config.php b/config.php new file mode 100644 index 0000000..93131d1 --- /dev/null +++ b/config.php @@ -0,0 +1,35 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); +} catch (PDOException $e) { + // In a production environment, log the error and show a generic message + error_log("Database connection failed: " . $e->getMessage()); + die("A problem occurred with the database connection. Please try again later."); +} +?> diff --git a/data_hybrid/browse_datasources.php b/data_hybrid/browse_datasources.php new file mode 100644 index 0000000..9ff90aa --- /dev/null +++ b/data_hybrid/browse_datasources.php @@ -0,0 +1,341 @@ +getUserDetails($user_id) : null; + +$uploadsWebPath = '../uploads/datasources/'; + +// Get filter parameters from GET request +$filter_category_id = $_GET['category_id'] ?? null; +if ($filter_category_id !== null) { + $filter_category_id = filter_var($filter_category_id, FILTER_VALIDATE_INT); + if ($filter_category_id === false) { + $filter_category_id = null; + } +} +$search_query = htmlspecialchars($_GET['search'] ?? '', ENT_QUOTES, 'UTF-8'); + +// Fetch data sources based on filters +$data_sources = []; +try { + $data_sources = $dataSourceManager->getDataSources( + null, // No owner filter for public browsing + 'Active', + $filter_category_id, + $search_query + ); +} catch (Exception $e) { + set_message('Error retrieving data sources: ' . $e->getMessage(), 'danger'); +} + +// Fetch all categories for the filter dropdown +$all_categories = []; +try { + $all_categories = $classificationManager->getAllCategories(); +} catch (Exception $e) { + error_log("Error fetching categories for browse_datasources: " . $e->getMessage()); +} +?> + + + + + +
+ + + +
+ + + + + + + + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+ + +
+
+
+
+
Category:
+
Type:
+

150 ? '...' : ''); ?>

+
+
    +
  • Data Owner:
  • +
  • Published: + + + + Not specified + +
  • +
+ ['label' => 'Questionnaire / Data Dictionary', 'icon' => 'fa-clipboard-list'], + 'dspsds_filename2' => ['label' => 'Protocol / User Guide', 'icon' => 'fa-book'], + 'dspsds_filename3' => ['label' => 'Other Supporting Document', 'icon' => 'fa-file-alt'], + ]; + ?> +
+ Supporting Documents +
    + $meta): ?> + +
  • + + + + + + + + (Not provided) + +
  • + +
+
+ + hasPermission($person_id, $ds['pkdspsds_id'], 'Read'); + $has_download_permission = $permissionManager->hasPermission($person_id, $ds['pkdspsds_id'], 'Download'); + } catch (Exception $e) { + error_log("Permission check error for user " . $person_id . " on DS " . $ds['pkdspsds_id'] . ": " . $e->getMessage()); + } + ?> + + + Read Access Granted + + + + + + + + Download File + + + + + + + +
+
+
+
+ + +
+
+ No active data sources found matching your criteria. +
+
+ +
+
+
+ + + + + + + + + + + + diff --git a/data_hybrid/dashboard.php b/data_hybrid/dashboard.php new file mode 100644 index 0000000..af4a76b --- /dev/null +++ b/data_hybrid/dashboard.php @@ -0,0 +1,213 @@ +getDataSources($person_id)); +$pending_permissions_count = count($data_source_manager->getPermissionRequestsForOwner($person_id, 'Pending')); +$usageByDataSource = $data_source_manager->getUsageByDataSourceForUser($person_id, 8); +$data_accesses_last_30_days = 0; +if (!empty($usageByDataSource)) { + foreach ($usageByDataSource as $row) { + $data_accesses_last_30_days += (int) ($row['usage_count'] ?? 0); + } +} + +?> + + + + + + +
+ + + + +
+ + + + + + + +
+
+
+
+
My Data Sources
+

+
+
+
+
+
+
+
Pending Permissions
+

+
+
+
+
+
+
+
Data Accesses (Last 30 Days)
+

+
+
+
+
+ + + +
+
+
+
+
Usage of Data Sources
+
+
+ + + +
No usage recorded yet for your account.
+ +
+
+
+
+ + + + +
+

Recent Activity on My Data Sources

+
    + 'User John Doe requested access to \'Population Census 2023\'.', 'time' => 'Just now', 'type' => 'info'], + ['text' => '\'Health Data Q1 2024\' was downloaded 5 times today.', 'time' => '2 hours ago', 'type' => 'success'], + ['text' => 'You updated \'Education Statistics 2022\'.', 'time' => 'Yesterday', 'type' => 'secondary'], + ]; + foreach ($recent_activities as $activity) { + echo '
  • '; + echo htmlspecialchars($activity['text']); + echo '' . htmlspecialchars($activity['time']) . ''; + echo '
  • '; + } + ?> +
+
+ +
+
+ + + + + + diff --git a/data_hybrid/download.php b/data_hybrid/download.php new file mode 100644 index 0000000..3bba306 --- /dev/null +++ b/data_hybrid/download.php @@ -0,0 +1,111 @@ +prepare($sql_insert); + $action = "Downloaded"; + $stmt_insert->execute([$datasource_id, $person_id, $action]); + +} catch (PDOException $e) { + // We now log the error and set a user-facing message + error_log("Error logging download: " . $e->getMessage()); + // Redirect with an error message, but still try to serve the file + set_message("An error occurred while logging the download.", "danger"); + // We do not die here, as we still want to try and serve the file +} + +// --- 3. Retrieve File Path and Name --- +$file_path = null; +$file_name = null; +try { + $sql_select = " + SELECT dspsds_filename, dspsds_title_en + FROM dsps_tbl_datasource + WHERE pkdspsds_id = ? + "; + $stmt = $pdo->prepare($sql_select); + $stmt->execute([$datasource_id]); + $row = $stmt->fetch(); + + if ($row) { + $file_name = $row['dspsds_filename']; + $download_label = $row['dspsds_title_en'] ?: 'datasource_' . $datasource_id; + } +} catch (PDOException $e) { + error_log("Error retrieving file info: " . $e->getMessage()); + die("An error occurred while retrieving file information."); +} + +if (empty($file_name)) { + die("File not found in the database."); +} + +// Handle external URLs +if (preg_match('/^https?:\\/\\//i', $file_name)) { + header('Location: ' . $file_name); + exit; +} + +$uploadsDir = realpath(__DIR__ . '/../uploads/datasources'); +if (!$uploadsDir) { + error_log('Uploads directory not found for download.'); + die('File storage directory is unavailable.'); +} + +$file_path = $uploadsDir . '/' . $file_name; + +// --- 4. Serve the File to the User --- +// Check if the file exists on the server +if (file_exists($file_path)) { + // Set headers to force a download + header('Content-Description: File Transfer'); + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename="' . basename($download_label . '_' . $file_name) . '"'); + header('Expires: 0'); + header('Cache-Control: must-revalidate'); + header('Pragma: public'); + header('Content-Length: ' . filesize($file_path)); + + // Clear output buffer + if (ob_get_level()) { + ob_clean(); + } + flush(); + + // Read the file and send it to the output buffer + readfile($file_path); + exit; +} else { + die("The file could not be found on the server at the specified path."); +} +?> diff --git a/data_hybrid/manage_my_datasources.php b/data_hybrid/manage_my_datasources.php new file mode 100644 index 0000000..8aab596 --- /dev/null +++ b/data_hybrid/manage_my_datasources.php @@ -0,0 +1,537 @@ +getAllDataTypes(); +$categories = $data_source_manager->getAllCategories(); +$primaryRulesMap = []; +foreach ($data_types as $type) { + $typeName = $type['dspstds_name_en'] ?? null; + $rules = $data_source_manager->getPrimaryFileRulesForType($typeName); + $acceptList = []; + foreach ($rules['extensions'] ?? [] as $ext) { + $acceptList[] = '.' . strtolower($ext); + } + $primaryRulesMap[$type['pkdspstds_id']] = [ + 'accept' => $acceptList, + 'description' => $rules['description'] ?? 'CSV, JSON, PDF, XLS, XLSX', + ]; +} +$defaultPrimaryRules = $data_source_manager->getPrimaryFileRulesForType(null); +$defaultPrimaryAccept = []; +foreach ($defaultPrimaryRules['extensions'] ?? [] as $ext) { + $defaultPrimaryAccept[] = '.' . strtolower($ext); +} +$initialPrimaryDescription = $defaultPrimaryRules['description'] ?? 'CSV, JSON, PDF, XLS, XLSX'; + + +// Handle form submissions +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $title_en = trim($_POST['title_en'] ?? ''); + $title_kh = trim($_POST['title_kh'] ?? ''); + $description = trim($_POST['description'] ?? ''); + $type_id = filter_var($_POST['type_id'] ?? '', FILTER_SANITIZE_NUMBER_INT); + $category_id = filter_var($_POST['category_id'] ?? '', FILTER_SANITIZE_NUMBER_INT); + $public_date = trim($_POST['public_date'] ?? ''); + $status = trim($_POST['status'] ?? 'Pending Review'); + $selectedDataType = null; + if (!empty($type_id)) { + $selectedDataType = $data_source_manager->getDataTypeById((int)$type_id); + } + $current_files = [ + 'dspsds_filename' => trim($_POST['current_filename'] ?? ''), + 'dspsds_filename1' => trim($_POST['current_filename1'] ?? ''), + 'dspsds_filename2' => trim($_POST['current_filename2'] ?? ''), + 'dspsds_filename3' => trim($_POST['current_filename3'] ?? ''), + ]; + + $final_files = $current_files; + $file_inputs = [ + 'dspsds_filename' => 'data_file', + 'dspsds_filename1' => 'data_file1', + 'dspsds_filename2' => 'data_file2', + 'dspsds_filename3' => 'data_file3', + ]; + $file_labels = [ + 'dspsds_filename' => 'Primary Data File', + 'dspsds_filename1' => 'Questionnaire / Data Dictionary', + 'dspsds_filename2' => 'Protocol / User Guide', + 'dspsds_filename3' => 'Other Supporting Document', + ]; + $remove_files = $_POST['remove_files'] ?? []; + if (!is_array($remove_files)) { + $remove_files = [$remove_files]; + } + + foreach ($file_inputs as $column => $inputName) { + if (!isset($_FILES[$inputName]) || $_FILES[$inputName]['error'] === UPLOAD_ERR_NO_FILE) { + continue; + } + + try { + if ($_FILES[$inputName]['error'] !== UPLOAD_ERR_OK) { + throw new Exception('Upload error code: ' . $_FILES[$inputName]['error']); + } + $fileRules = null; + if ($column === 'dspsds_filename') { + $fileRules = $data_source_manager->getPrimaryFileRulesForType($selectedDataType['dspstds_name_en'] ?? null); + } + $uploadedName = $data_source_manager->handleDataSourceFileUpload($_FILES[$inputName], $fileRules); + if ($uploadedName) { + if (!empty($current_files[$column]) && $current_files[$column] !== $uploadedName) { + $oldPath = $data_source_manager->getUploadDir() . $current_files[$column]; + if (is_file($oldPath)) { + unlink($oldPath); + } + } + $final_files[$column] = $uploadedName; + } + } catch (Exception $e) { + $friendlyLabel = $file_labels[$column] ?? $inputName; + set_message('File upload failed for ' . htmlspecialchars($friendlyLabel) . ': ' . $e->getMessage(), 'danger'); + $final_files[$column] = $current_files[$column]; + } + } + + foreach ($remove_files as $column) { + if (!array_key_exists($column, $final_files)) { + continue; + } + if (!empty($current_files[$column])) { + $oldPath = $data_source_manager->getUploadDir() . $current_files[$column]; + if (is_file($oldPath)) { + unlink($oldPath); + } + } + $final_files[$column] = ''; + } + + // Basic validation for required fields + if (empty($title_en) || empty($type_id) || empty($category_id)) { + set_message("Title, Data Type, and Category are required.", "danger"); + // Redirect to preserve form data or re-display form with errors + // For now, we'll just redirect to list, but a better UX would be to stay on the form + header("Location: manage_my_datasources.php?action=" . ($action === 'add_submit' ? 'add' : 'edit&id=' . $ds_id)); + exit(); + } + + // Determine the public date to pass to the add/update methods + // The DataSource class's add/update methods have logic for this, so we'll pass it as a string or null + $final_public_date = (!empty($public_date) && $status === 'Active') ? $public_date : null; + + + if ($action === 'add_submit') { + try { + // Corrected call to addDataSource + if ($data_source_manager->addDataSource( + $type_id, + $category_id, + $owner_person_id, // Data owner is the logged-in person + $final_files['dspsds_filename'], + $title_en, + $title_kh, + $description, + $status, + $user_id, // User who registered it (logged-in user) + $final_files['dspsds_filename1'], + $final_files['dspsds_filename2'], + $final_files['dspsds_filename3'] + )) { + set_message("Data source added successfully!", "success"); + } else { + set_message("Failed to add data source.", "danger"); + } + } catch (Exception $e) { + set_message("Error adding data source: " . $e->getMessage(), "danger"); + } + } elseif ($action === 'edit_submit' && $ds_id) { + try { + // Corrected call to updateDataSource + if ($data_source_manager->updateDataSource( + $ds_id, + $type_id, + $category_id, + $owner_person_id, // Data owner is the logged-in person + $final_files['dspsds_filename'], + $title_en, + $title_kh, + $description, + $status, + $user_id, // User who modified it (logged-in user) + $final_files['dspsds_filename1'], + $final_files['dspsds_filename2'], + $final_files['dspsds_filename3'] + )) { + set_message("Data source updated successfully!", "success"); + } else { + set_message("Failed to update data source.", "danger"); + } + } catch (Exception $e) { + set_message("Error updating data source: " . $e->getMessage(), "danger"); + } + } + // Redirect after POST to prevent form resubmission + header("Location: manage_my_datasources.php"); + exit(); +} + +// Handle GET actions +if ($action === 'edit' && $ds_id) { + $datasource_data = $data_source_manager->getDataSourceById($ds_id); + // Crucial security check: Ensure the logged-in owner actually owns this data source + if (!$datasource_data || $datasource_data['fkisp_id_of'] != $owner_person_id) { + set_message("Data source not found or you don't have permission to edit it.", "danger"); + header("Location: manage_my_datasources.php"); + exit(); + } +} elseif ($action === 'delete' && $ds_id) { + $datasource = $data_source_manager->getDataSourceById($ds_id); + // Crucial security check: Ensure the logged-in owner actually owns this data source + if ($datasource && $datasource['fkisp_id_of'] == $owner_person_id) { + // Delete associated file on the server + $fileColumns = ['dspsds_filename', 'dspsds_filename1', 'dspsds_filename2', 'dspsds_filename3']; + foreach ($fileColumns as $column) { + if (!empty($datasource[$column])) { + $filePath = $data_source_manager->getUploadDir() . $datasource[$column]; + if (is_file($filePath)) { + unlink($filePath); // Delete the file + } + } + } + if ($data_source_manager->deleteDataSource($ds_id)) { + set_message("Data source deleted successfully!", "success"); + } else { + set_message("Failed to delete data source.", "danger"); + } + } else { + set_message("Data source not found or you don't have permission to delete it.", "warning"); + } + header("Location: manage_my_datasources.php"); + exit(); +} + +// Fetch data sources for the current owner for display +$my_data_sources = $data_source_manager->getDataSources($owner_person_id); +$uploadsWebPath = '../uploads/datasources/'; + +?> + + + + + +
+ + + + +
+ + + + + + + + + + +
+
+
Data Source
+
+
+
+ + + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ 'Primary Data File', + 'id' => 'dsDataFile', + 'name' => 'data_file', + 'column' => 'dspsds_filename', + 'help' => 'Upload a file that matches the selected data type.', + ], + [ + 'label' => 'Questionnaire / Data Dictionary', + 'id' => 'dsDataFile1', + 'name' => 'data_file1', + 'column' => 'dspsds_filename1', + 'help' => 'Upload a supporting document (PDF, XLSX, etc.)', + ], + [ + 'label' => 'Protocol / User Guide', + 'id' => 'dsDataFile2', + 'name' => 'data_file2', + 'column' => 'dspsds_filename2', + 'help' => 'Upload a protocol or user guide (PDF, DOCX, etc.)', + ], + [ + 'label' => 'Other Supporting Document', + 'id' => 'dsDataFile3', + 'name' => 'data_file3', + 'column' => 'dspsds_filename3', + 'help' => 'Optional additional document.', + ], + ]; + ?> + + + +
+ + + + + Allowed formats: + + + + + + Current file: + +
+ + +
+ + + + +
+ +
+ + +
+
+ + +
+
+ Cancel + +
+
+
+
+ + +
+
+
My Data Sources
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + '; + if (!empty($primaryFile)) { + $isUrlPrimary = preg_match('/^https?:\/\//i', $primaryFile) === 1; + $primaryTarget = $isUrlPrimary ? $primaryFile : $uploadsWebPath . rawurlencode($primaryFile); + $primaryTitle = $isUrlPrimary ? 'External link' : 'Download Data Source'; + echo ''; + } else { + echo ''; + } + echo ''; + ?> + 'Questionnaire / Data Dictionary', + 'dspsds_filename2' => 'Protocol / User Guide', + 'dspsds_filename3' => 'Other Supporting Document', + ]; + foreach ($fileCells as $column => $label) { + $fileName = $ds[$column] ?? ''; + echo ''; + } + ?> + + + + + +
IDTitle (EN)TypeCategoryStatusData SourceQuestionnaireUser GuideSupporting DocRegistered DateActions
+ + + + '; + if (!empty($fileName)) { + $isUrl = preg_match('/^https?:\/\//i', $fileName) === 1; + $linkTarget = $isUrl ? $fileName : $uploadsWebPath . rawurlencode($fileName); + $titleAttr = $isUrl ? 'External link' : 'Download ' . $label; + echo ''; + } else { + echo ''; + } + echo ' + + +
+
+ +
You have not added any data sources yet.
+ +
+
+
+
+ + + + + + diff --git a/data_hybrid/manage_permissions.php b/data_hybrid/manage_permissions.php new file mode 100644 index 0000000..f5f8ee0 --- /dev/null +++ b/data_hybrid/manage_permissions.php @@ -0,0 +1,252 @@ +updatePermissionStatus($permission_id_to_update, $new_status, $user_id, $notes)) { + set_message("Permission request updated successfully!", "success"); + } else { + set_message("Failed to update permission request.", "danger"); + } + header("Location: manage_permissions.php"); + exit(); +} + +$pending_requests = $data_source_manager->getPermissionRequestsForOwner($owner_person_id, 'Pending'); +$all_requests = $data_source_manager->getPermissionRequestsForOwner($owner_person_id); // All statuses +?> + + + + + +
+ + + + +
+ + + + + + + +

Pending Requests

+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDData SourceRequested ByPermission TypeRequested DateNotesProofActions
+ —'; + ?> + + + + + View + + + N/A + + +
+ + + + +
+
+ + + + +
+
+
+ +
No pending permission requests.
+ +
+
+ +

All Permission Requests

+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDData SourceRequested ByPermission TypeStatusRequested DateNotesProofActions
+ + + + + —'; + ?> + + + + + View + + + N/A + + + +
+ + + + +
+ + No action + +
+
+ +
No permission requests found.
+ +
+
+ +
+
+ + + + diff --git a/data_hybrid/my_downloads.php b/data_hybrid/my_downloads.php new file mode 100644 index 0000000..11a081a --- /dev/null +++ b/data_hybrid/my_downloads.php @@ -0,0 +1,134 @@ +prepare(" + SELECT dsu.*, + ds.dspsds_title_en, + ds.dspsds_filename, + tds.dspstds_name_en + FROM dsps_tbl_datasource_used dsu + JOIN dsps_tbl_datasource ds + ON dsu.fkdspsdsused_id = ds.pkdspsds_id + JOIN dsps_tbl_typedatasource tds + ON ds.fkdspstds_id = tds.pkdspstds_id + WHERE dsu.fkisp_id_of = :person_id + AND dsu.dspsdspused_action = 'Downloaded' + ORDER BY dsu.dspsdspused_datetime DESC + "); + $stmt->execute(['person_id' => $person_id]); + $download_history = $stmt->fetchAll(); +} catch (PDOException $e) { + error_log("Error fetching download history: " . $e->getMessage()); + set_message("Error fetching download history.", "danger"); +} + +?> + + + + + +
+ + + +
+ + + + + + + +
+
+
Downloaded Data Sources
+
+
+ +
+ + + + + + + + + + + + + + + + + + + +
Data SourceTypeDownload DateFile
+ + + + Download Again + + + N/A (API or no direct file) + +
+
+ +
You have not downloaded any data sources yet.
+ +
+
+ +
+
+ + + + diff --git a/data_hybrid/my_permissions.php b/data_hybrid/my_permissions.php new file mode 100644 index 0000000..49c8187 --- /dev/null +++ b/data_hybrid/my_permissions.php @@ -0,0 +1,140 @@ +getUserDetails($user_id); + +// Fetch all permission requests for the logged-in user +$permissionRequests = []; +try { + $permissionRequests = $permissionManager->getPermissionsByPersonId($person_id); +} catch (Exception $e) { + set_message('Error retrieving permission requests: ' . $e->getMessage(), 'danger'); +} +?> + + + + + +
+ + + +
+ + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
Data SourceRequested ForDate SubmittedStatus
+ + +
+
+ +
+ You have not submitted any permission requests yet. Browse data sources to get started. +
+ +
+
+
+
+ + + + + diff --git a/data_hybrid/r_in_jupyter.php b/data_hybrid/r_in_jupyter.php new file mode 100644 index 0000000..eb8c1f6 --- /dev/null +++ b/data_hybrid/r_in_jupyter.php @@ -0,0 +1,162 @@ + [], 'missing' => [], 'workspace_dir' => null]; +$workspaceRelativeDir = null; +$workspaceError = null; + +if ($hasRJupyterAccess && isset($_SESSION['person_id'])) { + $dataSourceManager = new DataSource($pdo); + try { + $workspaceSync = $dataSourceManager->prepareJupyterWorkspace( + (int) $_SESSION['person_id'], + dirname(__DIR__) . '/uploads/jupyter_workspace' + ); + $workspaceRelativeDir = 'datasources/user_' . (int) $_SESSION['person_id']; + } catch (Exception $e) { + $workspaceError = $e->getMessage(); + } +} + +$jupyterBaseUrl = dsp_jupyter_base_url(); +$jupyterToken = dsp_jupyter_token(); +$jupyterIframeUrl = dsp_jupyter_iframe_url( + $jupyterBaseUrl, + $jupyterToken, + isset($_SESSION['person_id']) ? (int) $_SESSION['person_id'] : null +); +?> + + + + +
+ + +
+ + + + + + + +
+
+
Collaborative R Workspace
+ + Enabled + + Disabled + +
+
+ + +
+ Workspace error: +
+ +

+ Only Approved data sources are copied into + + inside Jupyter. Use these files when collaborating with Data Owners. +

+ +
+ + + + + + + + + + + + $syncedItem): ?> + + + + + + + + + +
#Data SourceData TypeCategoryFilename
+
+ +
+ You do not have any Approved data sources yet. Once your requests are approved, refresh this page. +
+ + +
+ Some items were skipped: +
    + +
  • + +
+
+ + +

+ Launch the embedded JupyterLab environment to build notebooks, explore shared datasets, and collaborate with Data Owners. + Use the Files panel to open existing work or create a new R Notebook from the launcher. +

+
+ +
+

+ Prefer a dedicated window? Open Jupyter in a new tab. +

+ +
+ +
+ R in JupyterHub is currently disabled for your account.
+ Contact a DAC Staff administrator to enable R/Jupyter access so you can analyse data directly from this workspace. +
+
+

+ Once access is enabled, refresh this page to launch the JupyterLab environment. +

+ +
+
+
+
+ + + diff --git a/data_owner/dashboard.php b/data_owner/dashboard.php new file mode 100644 index 0000000..9da47c6 --- /dev/null +++ b/data_owner/dashboard.php @@ -0,0 +1,214 @@ +getDataSources($person_id)); +$pending_permissions_count = count($data_source_manager->getPermissionRequestsForOwner($person_id, 'Pending')); +$usageByUser = $data_source_manager->getUsageByUserForOwner($person_id, 6); +$data_accesses_last_30_days = 0; +if (!empty($usageByUser)) { + foreach ($usageByUser as $row) { + $data_accesses_last_30_days += (int) ($row['usage_count'] ?? 0); + } +} + +?> + + + + + + +
+ + + + +
+ + + + + + + +
+
+
+
+
My Data Sources
+

+
+
+
+
+
+
+
Pending Permissions
+

+
+
+
+
+
+
+
Data Accesses (Last 30 Days)
+

+
+
+
+
+ + + +
+
+
+
+
Usage Breakdown by User
+
+
+ + + +
No data usage recorded yet.
+ +
+
+
+
+ + + + +
+

Recent Activity on My Data Sources

+
    + 'User John Doe requested access to \'Population Census 2023\'.', 'time' => 'Just now', 'type' => 'info'], + ['text' => '\'Health Data Q1 2024\' was downloaded 5 times today.', 'time' => '2 hours ago', 'type' => 'success'], + ['text' => 'You updated \'Education Statistics 2022\'.', 'time' => 'Yesterday', 'type' => 'secondary'], + ]; + foreach ($recent_activities as $activity) { + echo '
  • '; + echo htmlspecialchars($activity['text']); + echo '' . htmlspecialchars($activity['time']) . ''; + echo '
  • '; + } + ?> +
+
+ +
+
+ + + + + + diff --git a/data_owner/manage_my_datasources.php b/data_owner/manage_my_datasources.php new file mode 100644 index 0000000..797ca77 --- /dev/null +++ b/data_owner/manage_my_datasources.php @@ -0,0 +1,542 @@ +getAllDataTypes(); +$categories = $data_source_manager->getAllCategories(); +$primaryRulesMap = []; +foreach ($data_types as $type) { + $typeName = $type['dspstds_name_en'] ?? null; + $rules = $data_source_manager->getPrimaryFileRulesForType($typeName); + $acceptList = []; + foreach ($rules['extensions'] ?? [] as $ext) { + $acceptList[] = '.' . strtolower($ext); + } + $primaryRulesMap[$type['pkdspstds_id']] = [ + 'accept' => $acceptList, + 'description' => $rules['description'] ?? 'CSV, JSON, PDF, XLS, XLSX', + ]; +} +$defaultPrimaryRules = $data_source_manager->getPrimaryFileRulesForType(null); +$defaultPrimaryAccept = []; +foreach ($defaultPrimaryRules['extensions'] ?? [] as $ext) { + $defaultPrimaryAccept[] = '.' . strtolower($ext); +} +$initialPrimaryDescription = $defaultPrimaryRules['description'] ?? 'CSV, JSON, PDF, XLS, XLSX'; + + +// Handle form submissions +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $title_en = trim($_POST['title_en'] ?? ''); + $title_kh = trim($_POST['title_kh'] ?? ''); + $description = trim($_POST['description'] ?? ''); + $type_id = filter_var($_POST['type_id'] ?? '', FILTER_SANITIZE_NUMBER_INT); + $category_id = filter_var($_POST['category_id'] ?? '', FILTER_SANITIZE_NUMBER_INT); + $public_date = trim($_POST['public_date'] ?? ''); + $status = trim($_POST['status'] ?? 'Pending Review'); + $selectedDataType = null; + if (!empty($type_id)) { + $selectedDataType = $data_source_manager->getDataTypeById((int)$type_id); + } + $current_files = [ + 'dspsds_filename' => trim($_POST['current_filename'] ?? ''), + 'dspsds_filename1' => trim($_POST['current_filename1'] ?? ''), + 'dspsds_filename2' => trim($_POST['current_filename2'] ?? ''), + 'dspsds_filename3' => trim($_POST['current_filename3'] ?? ''), + ]; + + $final_files = $current_files; + $file_inputs = [ + 'dspsds_filename' => 'data_file', + 'dspsds_filename1' => 'data_file1', + 'dspsds_filename2' => 'data_file2', + 'dspsds_filename3' => 'data_file3', + ]; + $file_labels = [ + 'dspsds_filename' => 'Primary Data File', + 'dspsds_filename1' => 'Questionnaire / Data Dictionary', + 'dspsds_filename2' => 'Protocol / User Guide', + 'dspsds_filename3' => 'Other Supporting Document', + ]; + $remove_files = $_POST['remove_files'] ?? []; + if (!is_array($remove_files)) { + $remove_files = [$remove_files]; + } + + foreach ($file_inputs as $column => $inputName) { + if (!isset($_FILES[$inputName]) || $_FILES[$inputName]['error'] === UPLOAD_ERR_NO_FILE) { + continue; + } + + try { + if ($_FILES[$inputName]['error'] !== UPLOAD_ERR_OK) { + throw new Exception('Upload error code: ' . $_FILES[$inputName]['error']); + } + $fileRules = null; + if ($column === 'dspsds_filename') { + $fileRules = $data_source_manager->getPrimaryFileRulesForType($selectedDataType['dspstds_name_en'] ?? null); + } + $uploadedName = $data_source_manager->handleDataSourceFileUpload($_FILES[$inputName], $fileRules); + if ($uploadedName) { + if (!empty($current_files[$column]) && $current_files[$column] !== $uploadedName) { + $oldPath = $data_source_manager->getUploadDir() . $current_files[$column]; + if (is_file($oldPath)) { + unlink($oldPath); + } + } + $final_files[$column] = $uploadedName; + } + } catch (Exception $e) { + $friendlyLabel = $file_labels[$column] ?? $inputName; + set_message('File upload failed for ' . htmlspecialchars($friendlyLabel) . ': ' . $e->getMessage(), 'danger'); + $final_files[$column] = $current_files[$column]; + } + } + + foreach ($remove_files as $column) { + if (!array_key_exists($column, $final_files)) { + continue; + } + if (!empty($current_files[$column])) { + $oldPath = $data_source_manager->getUploadDir() . $current_files[$column]; + if (is_file($oldPath)) { + unlink($oldPath); + } + } + $final_files[$column] = ''; + } + + // Basic validation for required fields + if (empty($title_en) || empty($type_id) || empty($category_id)) { + set_message("Title, Data Type, and Category are required.", "danger"); + // Redirect to preserve form data or re-display form with errors + // For now, we'll just redirect to list, but a better UX would be to stay on the form + header("Location: manage_my_datasources.php?action=" . ($action === 'add_submit' ? 'add' : 'edit&id=' . $ds_id)); + exit(); + } + + // Determine the public date to pass to the add/update methods + // The DataSource class's add/update methods have logic for this, so we'll pass it as a string or null + $final_public_date = (!empty($public_date) && $status === 'Active') ? $public_date : null; + + + if ($action === 'add_submit') { + try { + // Corrected call to addDataSource + if ($data_source_manager->addDataSource( + $type_id, + $category_id, + $owner_person_id, // Data owner is the logged-in person + $final_files['dspsds_filename'], + $title_en, + $title_kh, + $description, + $status, + $user_id, // User who registered it (logged-in user) + $final_files['dspsds_filename1'], + $final_files['dspsds_filename2'], + $final_files['dspsds_filename3'] + )) { + set_message("Data source added successfully!", "success"); + } else { + set_message("Failed to add data source.", "danger"); + } + } catch (Exception $e) { + set_message("Error adding data source: " . $e->getMessage(), "danger"); + } + } elseif ($action === 'edit_submit' && $ds_id) { + try { + // Corrected call to updateDataSource + if ($data_source_manager->updateDataSource( + $ds_id, + $type_id, + $category_id, + $owner_person_id, // Data owner is the logged-in person + $final_files['dspsds_filename'], + $title_en, + $title_kh, + $description, + $status, + $user_id, // User who modified it (logged-in user) + $final_files['dspsds_filename1'], + $final_files['dspsds_filename2'], + $final_files['dspsds_filename3'] + )) { + set_message("Data source updated successfully!", "success"); + } else { + set_message("Failed to update data source.", "danger"); + } + } catch (Exception $e) { + set_message("Error updating data source: " . $e->getMessage(), "danger"); + } + } + // Redirect after POST to prevent form resubmission + header("Location: manage_my_datasources.php"); + exit(); +} + +// Handle GET actions +if ($action === 'edit' && $ds_id) { + $datasource_data = $data_source_manager->getDataSourceById($ds_id); + // Crucial security check: Ensure the logged-in owner actually owns this data source + if (!$datasource_data || $datasource_data['fkisp_id_of'] != $owner_person_id) { + set_message("Data source not found or you don't have permission to edit it.", "danger"); + header("Location: manage_my_datasources.php"); + exit(); + } +} elseif ($action === 'delete' && $ds_id) { + $datasource = $data_source_manager->getDataSourceById($ds_id); + // Crucial security check: Ensure the logged-in owner actually owns this data source + if ($datasource && $datasource['fkisp_id_of'] == $owner_person_id) { + // Delete associated file on the server + $fileColumns = ['dspsds_filename', 'dspsds_filename1', 'dspsds_filename2', 'dspsds_filename3']; + foreach ($fileColumns as $column) { + if (!empty($datasource[$column])) { + $filePath = $data_source_manager->getUploadDir() . $datasource[$column]; + if (is_file($filePath)) { + unlink($filePath); + } + } + } + if ($data_source_manager->deleteDataSource($ds_id)) { + set_message("Data source deleted successfully!", "success"); + } else { + set_message("Failed to delete data source.", "danger"); + } + } else { + set_message("Data source not found or you don't have permission to delete it.", "warning"); + } + header("Location: manage_my_datasources.php"); + exit(); +} + +// Fetch data sources for the current owner for display +$my_data_sources = $data_source_manager->getDataSources($owner_person_id); +$uploadsWebPath = '../uploads/datasources/'; + +?> + + + + + +
+ + + + +
+ + + + + + + + + + +
+
+
Data Source
+
+
+
+ + + + +
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ + + 'Primary Data File', + 'id' => 'dsDataFile', + 'name' => 'data_file', + 'column' => 'dspsds_filename', + 'help' => 'Upload a file that matches the selected data type.', + ], + [ + 'label' => 'Questionnaire / Data Dictionary', + 'id' => 'dsDataFile1', + 'name' => 'data_file1', + 'column' => 'dspsds_filename1', + 'help' => 'Upload a supporting document (PDF, XLSX, etc.)', + ], + [ + 'label' => 'Protocol / User Guide', + 'id' => 'dsDataFile2', + 'name' => 'data_file2', + 'column' => 'dspsds_filename2', + 'help' => 'Upload a protocol or user guide (PDF, DOCX, etc.)', + ], + [ + 'label' => 'Other Supporting Document', + 'id' => 'dsDataFile3', + 'name' => 'data_file3', + 'column' => 'dspsds_filename3', + 'help' => 'Optional additional document.', + ], + ]; + ?> + + + +
+ + + + + Allowed formats: + + + + + + Current file: + +
+ + +
+ + + + +
+ + +
+ + +
+
+ + +
+
+ Cancel + +
+
+
+
+ + +
+
+
My Data Sources
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + '; + if (!empty($primaryFile)) { + $isUrlPrimary = preg_match('/^https?:\/\//i', $primaryFile) === 1; + $primaryTarget = $isUrlPrimary ? $primaryFile : $uploadsWebPath . rawurlencode($primaryFile); + $primaryTitle = $isUrlPrimary ? 'External link' : 'Download Data Source'; + echo ''; + } else { + echo ''; + } + echo ''; + ?> + 'Questionnaire / Data Dictionary', + 'dspsds_filename2' => 'Protocol / User Guide', + 'dspsds_filename3' => 'Other Supporting Document', + ]; + foreach ($fileCells as $column => $label) { + $fileName = $ds[$column] ?? ''; + echo ''; + } + ?> + + + + + +
IDTitle (EN)TypeCategoryStatusData SourceQuestionnaireUser GuideSupporting DocRegistered DateActions
+ + + + '; + if (!empty($fileName)) { + $isUrl = preg_match('/^https?:\/\//i', $fileName) === 1; + $linkTarget = $isUrl ? $fileName : $uploadsWebPath . rawurlencode($fileName); + $titleAttr = $isUrl ? 'External link' : 'Download ' . $label; + echo ''; + } else { + echo ''; + } + echo ' + + +
+
+ +
You have not added any data sources yet.
+ +
+
+
+
+ + + + + + diff --git a/data_owner/manage_permissions.php b/data_owner/manage_permissions.php new file mode 100644 index 0000000..c956f9a --- /dev/null +++ b/data_owner/manage_permissions.php @@ -0,0 +1,252 @@ +updatePermissionStatus($permission_id_to_update, $new_status, $user_id, $notes)) { + set_message("Permission request updated successfully!", "success"); + } else { + set_message("Failed to update permission request.", "danger"); + } + header("Location: manage_permissions.php"); + exit(); +} + +$pending_requests = $data_source_manager->getPermissionRequestsForOwner($owner_person_id, 'Pending'); +$all_requests = $data_source_manager->getPermissionRequestsForOwner($owner_person_id); // All statuses +?> + + + + + +
+ + + + +
+ + + + + + + +

Pending Requests

+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDData SourceRequested ByPermission TypeRequested DateNotesProofActions
+ —'; + ?> + + + + + View + + + N/A + + +
+ + + + +
+
+ + + + +
+
+
+ +
No pending permission requests.
+ +
+
+ +

All Permission Requests

+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDData SourceRequested ByPermission TypeStatusRequested DateNotesProofActions
+ + + + + —'; + ?> + + + + + View + + + N/A + + + +
+ + + + +
+ + No action + +
+
+ +
No permission requests found.
+ +
+
+ +
+
+ + + + diff --git a/data_owner/my_analytics.php b/data_owner/my_analytics.php new file mode 100644 index 0000000..8a07fd3 --- /dev/null +++ b/data_owner/my_analytics.php @@ -0,0 +1,140 @@ +prepare(" + SELECT COUNT(dsu.pkdspsdspused_id) + FROM dsps_tbl_datasource_used dsu + JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id + WHERE ds.fkisp_id_of = :owner_person_id AND dsu.dspsdspused_action = 'Downloaded' + "); + $stmt->execute(['owner_person_id' => $owner_person_id]); + $total_downloads = $stmt->fetchColumn(); +} catch (PDOException $e) { + error_log("Error fetching total downloads: " . $e->getMessage()); +} + +// Example: Most viewed data sources (from anonymous views or usage logs) +$most_viewed_datasources = []; +try { + $stmt = $pdo->prepare(" + SELECT ds.dspsds_title_en, COUNT(da.pkdspsano_id) AS view_count + FROM dsps_tbl_anonymous da + JOIN dsps_tbl_datasource ds ON da.fkdspsds_id = ds.pkdspsds_id + WHERE ds.fkisp_id_of = :owner_person_id + GROUP BY ds.pkdspsds_id, ds.dspsds_title_en + ORDER BY view_count DESC + LIMIT 5 + "); + $stmt->execute(['owner_person_id' => $owner_person_id]); + $most_viewed_datasources = $stmt->fetchAll(); +} catch (PDOException $e) { + error_log("Error fetching most viewed data sources: " . $e->getMessage()); +} + +?> + + + + + +
+ + + + +
+ + + + + + + +
+
+
+
+
Total Downloads of My Data
+

+
+
+
+
+
+
+
Total Views of My Data Introductions
+

1500

+
+
+
+
+ +
+
+
Most Viewed Data Sources (Top 5)
+
+
+ +
    + +
  • + + Views +
  • + +
+ +
No view data available for your sources yet.
+ +
+
+ +
+
+ + + + \ No newline at end of file diff --git a/data_owner/run_r_scripts.php b/data_owner/run_r_scripts.php new file mode 100644 index 0000000..f2d7058 --- /dev/null +++ b/data_owner/run_r_scripts.php @@ -0,0 +1,166 @@ + [], 'missing' => [], 'workspace_dir' => null]; +$workspaceRelativeDir = null; +$workspaceError = null; + +if ($hasRJupyterAccess && isset($_SESSION['person_id'])) { + $dataSourceManager = new DataSource($pdo); + try { + $workspaceSync = $dataSourceManager->prepareJupyterWorkspace( + (int) $_SESSION['person_id'], + dirname(__DIR__) . '/uploads/jupyter_workspace' + ); + $workspaceRelativeDir = 'datasources/user_' . (int) $_SESSION['person_id']; + } catch (Exception $e) { + $workspaceError = $e->getMessage(); + } +} +?> + + + + + +
+ + + + +
+ + +
+
+
Work with R inside JupyterLab
+ + Enabled + + Disabled + +
+
+ + +
+ Workspace error: +
+ +

+ Approved data sources you manage or have access to are synced to + inside Jupyter. + Only datasets with Approved permissions appear here. +

+ +
+ + + + + + + + + + + + $syncedItem): ?> + + + + + + + + + +
#Data SourceData TypeCategoryFilename
+
+ +
+ You do not currently have any Approved data sources. Approve requests in the permissions panel to make them available here. +
+ + +
+ Some files were skipped: +
    + +
  • + +
+
+ + +

+ Launch the embedded Jupyter environment below to build, run, and share your R notebooks. Use the + toolbar menu inside Jupyter to create new R notebooks and access saved work under Files. +

+
+ +
+

+ Need more room? Open Jupyter in a new tab. +

+ +
+ +
+ R in JupyterHub is currently disabled for your account.
+ Contact a DAC Staff administrator to enable R/Jupyter access so you can analyse datasets directly from this workspace. +
+
+

+ After your access is approved, refresh this page to launch the embedded notebook environment. +

+ +
+
+ +
+
+ + + + diff --git a/data_user/browse_datasources.php b/data_user/browse_datasources.php new file mode 100644 index 0000000..65ed065 --- /dev/null +++ b/data_user/browse_datasources.php @@ -0,0 +1,341 @@ +getUserDetails($user_id) : null; + +$uploadsWebPath = '../uploads/datasources/'; + +// Get filter parameters from GET request +$filter_category_id = $_GET['category_id'] ?? null; +if ($filter_category_id !== null) { + $filter_category_id = filter_var($filter_category_id, FILTER_VALIDATE_INT); + if ($filter_category_id === false) { + $filter_category_id = null; + } +} +$search_query = htmlspecialchars($_GET['search'] ?? '', ENT_QUOTES, 'UTF-8'); + +// Fetch data sources based on filters +$data_sources = []; +try { + $data_sources = $dataSourceManager->getDataSources( + null, // No owner filter for public browsing + 'Active', + $filter_category_id, + $search_query + ); +} catch (Exception $e) { + set_message('Error retrieving data sources: ' . $e->getMessage(), 'danger'); +} + +// Fetch all categories for the filter dropdown +$all_categories = []; +try { + $all_categories = $classificationManager->getAllCategories(); +} catch (Exception $e) { + error_log("Error fetching categories for browse_datasources: " . $e->getMessage()); +} +?> + + + + + +
+ + + +
+ + + + + + + + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+ + +
+
+
+
+
Category:
+
Type:
+

150 ? '...' : ''); ?>

+
+
    +
  • Data Owner:
  • +
  • Published: + + + + Not specified + +
  • +
+ ['label' => 'Questionnaire / Data Dictionary', 'icon' => 'fa-clipboard-list'], + 'dspsds_filename2' => ['label' => 'Protocol / User Guide', 'icon' => 'fa-book'], + 'dspsds_filename3' => ['label' => 'Other Supporting Document', 'icon' => 'fa-file-alt'], + ]; + ?> +
+ Supporting Documents +
    + $meta): ?> + +
  • + + + + + + + + (Not provided) + +
  • + +
+
+ + hasPermission($person_id, $ds['pkdspsds_id'], 'Read'); + $has_download_permission = $permissionManager->hasPermission($person_id, $ds['pkdspsds_id'], 'Download'); + } catch (Exception $e) { + error_log("Permission check error for user " . $person_id . " on DS " . $ds['pkdspsds_id'] . ": " . $e->getMessage()); + } + ?> + + + Read Access Granted + + + + + + + + Download File + + + + + + + +
+
+
+
+ + +
+
+ No active data sources found matching your criteria. +
+
+ +
+
+
+ + + + + + + + + + + + diff --git a/data_user/dashboard.php b/data_user/dashboard.php new file mode 100644 index 0000000..bf88f2d --- /dev/null +++ b/data_user/dashboard.php @@ -0,0 +1,166 @@ +prepare(" + SELECT COUNT(DISTINCT dp.fkdspsds_id) + FROM dsps_tbl_datasource_permission dp + WHERE dp.fkisp_id_of = :person_id AND dp.dspsdsp_status = 'Approved' + "); + $stmt->execute(['person_id' => $person_id]); + $approved_datasources_count = $stmt->fetchColumn(); +} catch (PDOException $e) { + error_log("Error fetching approved datasources count: " . $e->getMessage()); +} + +$pending_requests_count = 0; +try { + $stmt = $pdo->prepare(" + SELECT COUNT(dp.pkdspsdsp_id) + FROM dsps_tbl_datasource_permission dp + WHERE dp.fkisp_id_of = :person_id AND dp.dspsdsp_status = 'Pending' + "); + $stmt->execute(['person_id' => $person_id]); + $pending_requests_count = $stmt->fetchColumn(); +} catch (PDOException $e) { + error_log("Error fetching pending requests count: " . $e->getMessage()); +} + +$my_downloads_count = 0; +try { + $stmt = $pdo->prepare(" + SELECT COUNT(*) FROM dsps_tbl_datasource_used + WHERE fkisp_id_of = :person_id AND dspsdspused_action = 'Downloaded' + "); + $stmt->execute(['person_id' => $person_id]); + $my_downloads_count = $stmt->fetchColumn(); +} catch (PDOException $e) { + error_log("Error fetching my downloads count: " . $e->getMessage()); +} + +?> + + + + + +
+ + + +
+ + + + + + + +
+
+
+
+
Approved Data Sources
+

+
+
+
+
+
+
+
Pending Requests
+

+
+
+
+
+
+
+
My Total Downloads
+

+
+
+
+
+ + + + +
+

Recently Accessed Data Sources

+
    + 'Health Survey 2023', 'action' => 'Downloaded', 'time' => '1 hour ago'], + ['title' => 'Education Statistics 2022', 'action' => 'Viewed Details', 'time' => 'Yesterday'], + ['title' => 'Climate Data Phnom Penh', 'action' => 'Ran Analysis', 'time' => '3 days ago'], + ]; + if (!empty($recent_accesses)) { + foreach ($recent_accesses as $access) { + echo '
  • '; + echo htmlspecialchars($access['title']) . ' - ' . htmlspecialchars($access['action']); + echo '' . htmlspecialchars($access['time']) . ''; + echo '
  • '; + } + } else { + echo '
  • No recent activity.
  • '; + } + ?> +
+
+ +
+
+ + + + \ No newline at end of file diff --git a/data_user/download.php b/data_user/download.php new file mode 100644 index 0000000..e6efc16 --- /dev/null +++ b/data_user/download.php @@ -0,0 +1,109 @@ +prepare($sql_insert); + $action = "Downloaded"; + $stmt_insert->execute([$datasource_id, $person_id, $action]); + +} catch (PDOException $e) { + // We now log the error and set a user-facing message + error_log("Error logging download: " . $e->getMessage()); + // Redirect with an error message, but still try to serve the file + set_message("An error occurred while logging the download.", "danger"); + // We do not die here, as we still want to try and serve the file +} + +// --- 3. Retrieve File Path and Name --- +$file_path = null; +$file_name = null; +try { + $sql_select = " + SELECT dspsds_filename, dspsds_title_en + FROM dsps_tbl_datasource + WHERE pkdspsds_id = ? + "; + $stmt = $pdo->prepare($sql_select); + $stmt->execute([$datasource_id]); + $row = $stmt->fetch(); + + if ($row) { + $file_name = $row['dspsds_filename']; + $download_label = $row['dspsds_title_en'] ?: 'datasource_' . $datasource_id; + } +} catch (PDOException $e) { + error_log("Error retrieving file info: " . $e->getMessage()); + die("An error occurred while retrieving file information."); +} + +if (empty($file_name)) { + die("File not found in the database."); +} + +if (preg_match('/^https?:\\/\\//i', $file_name)) { + header('Location: ' . $file_name); + exit; +} + +$uploadsDir = realpath(__DIR__ . '/../uploads/datasources'); +if (!$uploadsDir) { + error_log('Uploads directory not found for download.'); + die('File storage directory is unavailable.'); +} + +$file_path = $uploadsDir . '/' . $file_name; + +// --- 4. Serve the File to the User --- +// Check if the file exists on the server +if (file_exists($file_path)) { + // Set headers to force a download + header('Content-Description: File Transfer'); + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename="' . basename($download_label . '_' . $file_name) . '"'); + header('Expires: 0'); + header('Cache-Control: must-revalidate'); + header('Pragma: public'); + header('Content-Length: ' . filesize($file_path)); + + if (ob_get_level()) { + ob_clean(); + } + flush(); + + // Read the file and send it to the output buffer + readfile($file_path); + exit; +} else { + die("The file could not be found on the server at the specified path."); +} +?> diff --git a/data_user/indexTesting.php b/data_user/indexTesting.php new file mode 100644 index 0000000..0b8e325 --- /dev/null +++ b/data_user/indexTesting.php @@ -0,0 +1,58 @@ + diff --git a/data_user/my_downloads.php b/data_user/my_downloads.php new file mode 100644 index 0000000..1fbf012 --- /dev/null +++ b/data_user/my_downloads.php @@ -0,0 +1,134 @@ +prepare(" + SELECT dsu.*, + ds.dspsds_title_en, + ds.dspsds_filename, + tds.dspstds_name_en + FROM dsps_tbl_datasource_used dsu + JOIN dsps_tbl_datasource ds + ON dsu.fkdspsdsused_id = ds.pkdspsds_id + JOIN dsps_tbl_typedatasource tds + ON ds.fkdspstds_id = tds.pkdspstds_id + WHERE dsu.fkisp_id_of = :person_id + AND dsu.dspsdspused_action = 'Downloaded' + ORDER BY dsu.dspsdspused_datetime DESC + "); + $stmt->execute(['person_id' => $person_id]); + $download_history = $stmt->fetchAll(); +} catch (PDOException $e) { + error_log("Error fetching download history: " . $e->getMessage()); + set_message("Error fetching download history.", "danger"); +} + +?> + + + + + +
+ + + +
+ + + + + + + +
+
+
Downloaded Data Sources
+
+
+ +
+ + + + + + + + + + + + + + + + + + + +
Data SourceTypeDownload DateFile
+ + + + Download Again + + + N/A (API or no direct file) + +
+
+ +
You have not downloaded any data sources yet.
+ +
+
+ +
+
+ + + + diff --git a/data_user/my_permissions.php b/data_user/my_permissions.php new file mode 100644 index 0000000..2bf19d3 --- /dev/null +++ b/data_user/my_permissions.php @@ -0,0 +1,150 @@ +getUserDetails($user_id); + +// Fetch all permission requests for the logged-in user +$permissionRequests = []; +try { + $permissionRequests = $permissionManager->getPermissionsByPersonId($person_id); +} catch (Exception $e) { + set_message('Error retrieving permission requests: ' . $e->getMessage(), 'danger'); +} +?> + + + + + +
+ + + +
+ + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
Data SourceRequested ForDate SubmittedProofStatus
+ + + View + + + N/A + + + + +
+
+ +
+ You have not submitted any permission requests yet. Browse data sources to get started. +
+ +
+
+
+
+ + + + + diff --git a/data_user/process_request_permission.php b/data_user/process_request_permission.php new file mode 100644 index 0000000..4f90ce9 --- /dev/null +++ b/data_user/process_request_permission.php @@ -0,0 +1,147 @@ + $maxSize) { + set_and_redirect('Proof files must be smaller than 10 MB.', 'danger'); + } + + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->file($file['tmp_name']) ?: ''; + if ($mimeType !== 'application/pdf') { + set_and_redirect('Only PDF files are accepted as proof.', 'danger'); + } + + $uploadDir = __DIR__ . '/../uploads/permission_proofs'; + if (!is_dir($uploadDir) && !mkdir($uploadDir, 0775, true)) { + set_and_redirect('Unable to create the proof upload directory. Contact an administrator.', 'danger'); + } + + if (!is_writable($uploadDir)) { + set_and_redirect('The proof upload directory is not writable. Contact an administrator.', 'danger'); + } + + $random = bin2hex(random_bytes(8)); + $filename = sprintf('%d_%s.pdf', $personId, $random); + $destination = $uploadDir . '/' . $filename; + + if (!move_uploaded_file($file['tmp_name'], $destination)) { + set_and_redirect('Failed to store your proof document. Please try again.', 'danger'); + } + + return 'permission_proofs/' . $filename; +} + +// A helper function to set a session message and redirect +function set_and_redirect($message, $type, $page = 'browse_datasources.php') { + $_SESSION['message'] = $message; + $_SESSION['message_type'] = $type; + // Check if headers have already been sent. + // This is the most common reason for redirects to fail. + if (headers_sent()) { + echo "
Redirect failed. Headers already sent. Please go back to the previous page to view the message.
"; + echo "Message: " . htmlspecialchars($message); + exit; + } else { + header('Location: ' . $page); + exit; + } +} + +// 1. Check if the user is logged in +if (!isset($_SESSION['person_id']) || !isset($_SESSION['user_id'])) { + set_and_redirect('You must be logged in to request permission.', 'danger'); +} + +$user_id = $_SESSION['user_id']; +$person_id = $_SESSION['person_id']; + +// 2. Validate and sanitize POST data +$dataSourceId = filter_input(INPUT_POST, 'data_source_id', FILTER_VALIDATE_INT); + +// Replace deprecated FILTER_SANITIZE_STRING +$permissionType = trim(filter_input(INPUT_POST, 'permission_type', FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW)); +$notes = trim(filter_input(INPUT_POST, 'notes', FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW)); + +// Check if required fields are missing or invalid +if (!$dataSourceId || empty($permissionType) || empty($notes)) { + set_and_redirect('Invalid or missing request details. Please try again.', 'danger'); +} + +$proofPath = handle_proof_upload($_FILES['proof_file'] ?? null, $person_id); + +// 3. Instantiate the Permission class and process the request +try { + $permissionManager = new Permission($pdo); + + // Check if a similar request (for the same user, DS, and type) already exists. + $existingRequest = $permissionManager->getPendingRequest($person_id, $dataSourceId, $permissionType); + + if ($existingRequest) { + set_and_redirect('A request for this permission type is already pending.', 'warning'); + } + + // Attempt to add the new permission request to the database. + $success = $permissionManager->addPermissionRequest( + $person_id, + $dataSourceId, + $permissionType, + 'Pending', // Set status to Pending + $notes, + $proofPath + ); + + if ($success) { + set_and_redirect('Your request for ' . htmlspecialchars($permissionType) . ' access has been submitted successfully.', 'success'); + } else { + set_and_redirect('Failed to submit your request. Please try again later.', 'danger'); + } + +} catch (Exception $e) { + // Log the detailed error for debugging, but show a generic message to the user. + error_log("Error submitting permission request: " . $e->getMessage()); + set_and_redirect('An unexpected error occurred. Please try again.', 'danger'); +} + +?> diff --git a/data_user/r_in_jupyter.php b/data_user/r_in_jupyter.php new file mode 100644 index 0000000..f18c344 --- /dev/null +++ b/data_user/r_in_jupyter.php @@ -0,0 +1,161 @@ + [], 'missing' => [], 'workspace_dir' => null]; +$workspaceRelativeDir = null; +$workspaceError = null; + +if ($hasRJupyterAccess && isset($_SESSION['person_id'])) { + $dataSourceManager = new DataSource($pdo); + try { + $workspaceSync = $dataSourceManager->prepareJupyterWorkspace( + (int) $_SESSION['person_id'], + dirname(__DIR__) . '/uploads/jupyter_workspace' + ); + $workspaceRelativeDir = 'datasources/user_' . (int) $_SESSION['person_id']; + } catch (Exception $e) { + $workspaceError = $e->getMessage(); + } +} + +$jupyterBaseUrl = dsp_jupyter_base_url(); +$jupyterToken = dsp_jupyter_token(); +$jupyterIframeUrl = dsp_jupyter_iframe_url( + $jupyterBaseUrl, + $jupyterToken, + isset($_SESSION['person_id']) ? (int) $_SESSION['person_id'] : null +); +?> + + + + +
+ + +
+ + + + + + + +
+
+
R Notebook Workspace
+ + Enabled + + Disabled + +
+
+ + +
+ Workspace error: +
+ +

+ Approved datasets are available inside Jupyter at + . + Only data sources you have Approved access to will appear. +

+ +
+ + + + + + + + + + + + $syncedItem): ?> + + + + + + + + + +
#Data SourceData TypeCategoryFilename
+
+ +
+ You currently have no Approved data sources. Request access from DAC Staff or Data Owners to populate this workspace. +
+ + +
+ Some datasets were skipped: +
    + +
  • + +
+
+ + +

+ Use the embedded JupyterLab session to explore approved datasets, run R notebooks, or collaborate with Data Owners and DAC Staff. +

+
+ +
+

+ Need more space? Open Jupyter in a new tab. +

+ +
+ +
+ R in JupyterHub is currently disabled for your account.
+ Request R/Jupyter access from DAC Staff so you can run notebooks directly from this portal. +
+
+

+ After your access is approved, revisit this page to launch the notebook workspace. +

+ +
+
+
+
+ + + diff --git a/db/migrations/20241103_oauth_tables.sql b/db/migrations/20241103_oauth_tables.sql new file mode 100644 index 0000000..9f01c4a --- /dev/null +++ b/db/migrations/20241103_oauth_tables.sql @@ -0,0 +1,83 @@ +-- +-- DSP OAuth schema for JupyterHub integration +-- +-- Run inside the MySQL container, e.g.: +-- docker-compose exec db mysql -u root -p niph_dsps < db/migrations/20241103_oauth_tables.sql +-- + +START TRANSACTION; + +CREATE TABLE IF NOT EXISTS `dsp_oauth_clients` ( + `client_id` varchar(128) NOT NULL, + `client_name` varchar(255) NOT NULL, + `client_secret_hash` varchar(255) DEFAULT NULL, + `redirect_uris` text NOT NULL, + `allowed_scopes` varchar(255) DEFAULT NULL, + `is_confidential` tinyint(1) NOT NULL DEFAULT 1, + `is_revoked` tinyint(1) NOT NULL DEFAULT 0, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`client_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `dsp_oauth_auth_codes` ( + `code_hash` char(64) NOT NULL, + `client_id` varchar(128) NOT NULL, + `person_id` int(11) NOT NULL, + `scope` varchar(255) DEFAULT NULL, + `redirect_uri` varchar(2000) NOT NULL, + `expires_at` datetime NOT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`code_hash`), + KEY `idx_oauth_auth_client` (`client_id`), + KEY `idx_oauth_auth_expires` (`expires_at`), + CONSTRAINT `dsp_oauth_auth_codes_ibfk_1` FOREIGN KEY (`client_id`) REFERENCES `dsp_oauth_clients` (`client_id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `dsp_oauth_auth_codes_ibfk_2` FOREIGN KEY (`person_id`) REFERENCES `ist_tbl_people` (`pkisp_id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `dsp_oauth_access_tokens` ( + `token_hash` char(64) NOT NULL, + `client_id` varchar(128) NOT NULL, + `person_id` int(11) NOT NULL, + `scope` varchar(255) DEFAULT NULL, + `expires_at` datetime NOT NULL, + `refresh_token_hash` char(64) DEFAULT NULL, + `refresh_expires_at` datetime DEFAULT NULL, + `is_revoked` tinyint(1) NOT NULL DEFAULT 0, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `last_used_at` datetime DEFAULT NULL, + `revoked_at` datetime DEFAULT NULL, + PRIMARY KEY (`token_hash`), + KEY `idx_oauth_access_client` (`client_id`), + KEY `idx_oauth_access_person` (`person_id`), + KEY `idx_oauth_access_refresh` (`refresh_token_hash`), + KEY `idx_oauth_access_expires` (`expires_at`), + CONSTRAINT `dsp_oauth_access_tokens_ibfk_1` FOREIGN KEY (`client_id`) REFERENCES `dsp_oauth_clients` (`client_id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `dsp_oauth_access_tokens_ibfk_2` FOREIGN KEY (`person_id`) REFERENCES `ist_tbl_people` (`pkisp_id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +COMMIT; + +-- +-- Optional helper to register the JupyterHub client. +-- Replace the placeholder secret/redirects before running. +-- +-- INSERT INTO dsp_oauth_clients ( +-- client_id, +-- client_name, +-- client_secret_hash, +-- redirect_uris, +-- allowed_scopes +-- ) VALUES ( +-- 'hub-client', +-- 'DSP JupyterHub', +-- '$2y$10$replace_this_with_password_hash', +-- 'https://hub.example.com/hub/oauth_callback', +-- 'profile' +-- ) +-- ON DUPLICATE KEY UPDATE +-- client_name = VALUES(client_name), +-- client_secret_hash = VALUES(client_secret_hash), +-- redirect_uris = VALUES(redirect_uris), +-- allowed_scopes = VALUES(allowed_scopes), +-- updated_at = NOW(); diff --git a/db/migrations/20250114_add_permission_proof_path.sql b/db/migrations/20250114_add_permission_proof_path.sql new file mode 100644 index 0000000..dd82303 --- /dev/null +++ b/db/migrations/20250114_add_permission_proof_path.sql @@ -0,0 +1,2 @@ +ALTER TABLE dsps_tbl_datasource_permission + ADD COLUMN dspsdsp_proof_path VARCHAR(255) DEFAULT NULL AFTER dspsdsp_notes; diff --git a/db/niph_dsps.sql b/db/niph_dsps.sql new file mode 100644 index 0000000..b74dc59 --- /dev/null +++ b/db/niph_dsps.sql @@ -0,0 +1,782 @@ +-- phpMyAdmin SQL Dump +-- version 5.2.1 +-- https://www.phpmyadmin.net/ +-- +-- Host: localhost +-- Generation Time: Oct 04, 2025 at 08:56 AM +-- Server version: 10.4.28-MariaDB +-- PHP Version: 8.2.4 + +SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; +START TRANSACTION; +SET time_zone = "+00:00"; + + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8mb4 */; + +-- +-- Database: `niph_dsps` +-- + +-- -------------------------------------------------------- + +-- +-- Table structure for table `dsps_tbl_announcement` +-- + +CREATE TABLE `dsps_tbl_announcement` ( + `pkdspsann_id` int(11) NOT NULL, + `dspsann_reg_datetime` datetime DEFAULT current_timestamp(), + `dspsann_mod_datetime` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `dspsann_reg_by` int(11) DEFAULT NULL COMMENT 'FK to ist_tbl_users', + `dspsann_title` varchar(255) NOT NULL, + `dspsann_description` text NOT NULL, + `dspsann_photopath` varchar(255) DEFAULT NULL COMMENT 'Optional image path for announcement', + `dspsann_status` enum('Published','Draft','Archived') NOT NULL DEFAULT 'Draft' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- Dumping data for table `dsps_tbl_announcement` +-- + +INSERT INTO `dsps_tbl_announcement` (`pkdspsann_id`, `dspsann_reg_datetime`, `dspsann_mod_datetime`, `dspsann_reg_by`, `dspsann_title`, `dspsann_description`, `dspsann_photopath`, `dspsann_status`) VALUES +(1, '2025-07-19 16:31:56', '2025-07-19 16:33:19', 1, 'Important Policy Update for Data Access', 'We have updated our data access policy to streamline the request process. Please review the new guidelines in the \"My Permissions\" section for Data Users and \"Manage Permissions\" for Data Owners.', NULL, 'Published'), +(2, '2025-07-19 16:31:56', '2025-07-19 16:33:23', 1, 'New Research Datasets on Infectious Diseases', 'Exciting new datasets related to recent infectious disease outbreaks are now available for approved researchers. These include anonymized patient data and epidemiological trends.', NULL, 'Published'), +(3, '2025-07-19 16:31:56', '2025-07-19 16:31:56', 1, 'Platform Maintenance Scheduled for July 25th', 'Please be advised that the DSPS will undergo scheduled maintenance on July 25th, 2025, from 10:00 PM to 2:00 AM (ICT). During this period, the platform may be temporarily unavailable.', NULL, 'Published'), +(4, '2025-07-19 16:31:56', '2025-08-11 08:47:22', 1, 'Call for Data Sharing Proposals', 'NIPH is inviting proposals from researchers and institutions interested in sharing their public health datasets through our platform. Visit the \"Data Owner\" section for more details.', '', 'Archived'), +(5, '2025-07-19 16:31:56', '2025-08-11 08:47:15', 1, 'Draft Announcement - Internal Review', 'This is a draft announcement for internal review only. It is not visible to the public.', '', 'Archived'); + +-- -------------------------------------------------------- + +-- +-- Table structure for table `dsps_tbl_anonymous` +-- + +CREATE TABLE `dsps_tbl_anonymous` ( + `pkdspsano_id` int(11) NOT NULL, + `dspsano_reg_datetime` datetime DEFAULT current_timestamp(), + `fkdspsds_id` int(11) NOT NULL COMMENT 'FK to dsps_tbl_datasource', + `dspsano_client_ip` varchar(45) DEFAULT NULL COMMENT 'IPv4 or IPv6 address', + `dspsano_datetime` datetime DEFAULT current_timestamp(), + `dspsano_action` varchar(100) DEFAULT NULL COMMENT 'e.g., View Introduction, Clicked Link' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `dsps_tbl_datasource` +-- + +CREATE TABLE `dsps_tbl_datasource` ( + `pkdspsds_id` int(11) NOT NULL, + `dspsds_reg_datetime` datetime DEFAULT current_timestamp(), + `dspsds_mod_datetime` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `dspsds_reg_by` int(11) DEFAULT NULL COMMENT 'FK to ist_tbl_users (who registered this data source)', + `fkdspstds_id` int(11) NOT NULL COMMENT 'FK to dsps_tbl_typedatasource', + `fkdspscate_id` int(11) NOT NULL COMMENT 'FK to dsps_tbl_dspscategory', + `fkisp_id_of` int(11) NOT NULL COMMENT 'FK to ist_tbl_people (Data Owner of this source)', + `dspsds_filename` varchar(255) DEFAULT NULL COMMENT 'File path/name for CSV/JSON/PDF, or API endpoint URL', + `dspsds_title_en` varchar(255) NOT NULL, + `dspsds_title_kh` varchar(255) DEFAULT NULL, + `dspsds_description` text DEFAULT NULL, + `dspsds_public_date` date DEFAULT NULL COMMENT 'Date when data source was made public', + `dspsds_status` varchar(20) NOT NULL DEFAULT 'Pending Review', + `dspsds_filename1` varchar(250) DEFAULT NULL, + `dspsds_filename2` varchar(250) DEFAULT NULL, + `dspsds_filename3` varchar(250) DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- Dumping data for table `dsps_tbl_datasource` +-- + +INSERT INTO `dsps_tbl_datasource` (`pkdspsds_id`, `dspsds_reg_datetime`, `dspsds_mod_datetime`, `dspsds_reg_by`, `fkdspstds_id`, `fkdspscate_id`, `fkisp_id_of`, `dspsds_filename`, `dspsds_title_en`, `dspsds_title_kh`, `dspsds_description`, `dspsds_public_date`, `dspsds_status`, `dspsds_filename1`, `dspsds_filename2`, `dspsds_filename3`) VALUES +(4, '2025-07-20 12:43:12', '2025-07-20 13:01:06', 2, 1, 3, 2, 'population_health_2023.csv', 'National Population Health Survey 2023', 'ការស្ទង់មតិសុខភាពប្រជាជនជាតិឆ្នាំ២០២៣', 'Comprehensive dataset on health indicators, demographics, and disease prevalence across Cambodia. Data collected in 2023.', NULL, 'Active', '', NULL, ''), +(9, '2025-07-20 12:46:56', '2025-07-26 10:26:42', 2, 1, 1, 2, 'infectious_disease_api_endpoint.json', 'API Endpoint for Infectious Disease Trends', 'ចំណុចបញ្ចប់ API សម្រាប់និន្នាការជំងឺឆ្លង', 'Real-time data access for common infectious diseases, including incidence rates and geographical distribution. Requires API key.', NULL, 'Active', '', NULL, ''), +(10, '2025-08-09 12:36:52', '2025-08-09 12:36:52', 2, 1, 2, 2, 'datasource_6896de743a3f0.csv', 'TEST001', 'test001', 'test', NULL, 'Active', '', NULL, ''), +(11, '2025-08-30 13:55:40', '2025-08-30 13:55:40', 206, 4, 1, 104, 'datasource_68b2a06c7245e.pdf', 'test for Contributor', 'test', 'test for Contributor', NULL, 'Active', '', NULL, ''), +(12, '2025-09-06 15:55:39', '2025-09-06 15:55:39', 2, 1, 2, 2, 'datasource_68bbf70b91bc0.xlsx', 'Test5555', '', 'Test5555', NULL, 'Active', NULL, NULL, NULL); + +-- -------------------------------------------------------- + +-- +-- Table structure for table `dsps_tbl_datasource_permission` +-- + +CREATE TABLE `dsps_tbl_datasource_permission` ( + `pkdspsdsp_id` int(11) NOT NULL, + `dspsdsp_reg_datetime` datetime DEFAULT current_timestamp(), + `dspsdsp_mod_datetime` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `dspsdsp_reg_by` int(11) DEFAULT NULL COMMENT 'FK to ist_tbl_users (who granted permission, usually Data Owner)', + `fkdspsds_id` int(11) NOT NULL COMMENT 'FK to dsps_tbl_datasource', + `fkisp_id_of` int(11) NOT NULL COMMENT 'FK to ist_tbl_people (User who is granted permission)', + `dspsdsp_datetime` datetime DEFAULT current_timestamp() COMMENT 'When permission was granted/requested', + `dspsdsp_permission` enum('Read','Download','Analyze') NOT NULL, + `dspsdsp_notes` text DEFAULT NULL, + `dspsdsp_proof_path` varchar(255) DEFAULT NULL, + `dspsdsp_status` enum('Approved','Pending','Rejected','Revoked') NOT NULL DEFAULT 'Pending' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- Dumping data for table `dsps_tbl_datasource_permission` +-- + +INSERT INTO `dsps_tbl_datasource_permission` (`pkdspsdsp_id`, `dspsdsp_reg_datetime`, `dspsdsp_mod_datetime`, `dspsdsp_reg_by`, `fkdspsds_id`, `fkisp_id_of`, `dspsdsp_datetime`, `dspsdsp_permission`, `dspsdsp_notes`, `dspsdsp_proof_path`, `dspsdsp_status`) VALUES +(1, '2025-08-09 11:23:07', '2025-08-09 11:46:06', 2, 9, 3, '2025-08-09 11:23:07', 'Download', '', NULL, 'Approved'), +(4, '2025-08-09 11:39:22', '2025-08-09 11:46:03', 2, 4, 3, '2025-08-09 11:39:22', 'Read', '', NULL, 'Approved'), +(5, '2025-08-09 12:48:09', '2025-08-09 12:48:39', 2, 10, 3, '2025-08-09 12:48:09', 'Download', '', NULL, 'Approved'), +(6, '2025-08-30 14:00:25', '2025-08-30 14:01:09', 206, 11, 105, '2025-08-30 14:00:25', 'Download', '', NULL, 'Approved'), +(7, '2025-08-30 14:26:17', '2025-08-30 14:29:27', 2, 10, 104, '2025-08-30 14:26:17', 'Download', '', NULL, 'Approved'); + +-- -------------------------------------------------------- + +-- +-- Table structure for table `dsps_tbl_datasource_used` +-- + +CREATE TABLE `dsps_tbl_datasource_used` ( + `pkdspsdspused_id` int(11) NOT NULL, + `dspsdspused_reg_datetime` datetime DEFAULT current_timestamp(), + `dspsdspused_mod_datetime` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `dspsdspused_reg_by` int(11) DEFAULT NULL COMMENT 'FK to ist_tbl_users (who performed the action)', + `fkdspsdsused_id` int(11) NOT NULL COMMENT 'FK to dsps_tbl_datasource (the data source that was used)', + `fkisp_id_of` int(11) NOT NULL COMMENT 'FK to ist_tbl_people (the user who used it)', + `dspsdspused_datetime` datetime DEFAULT current_timestamp(), + `dspsdspused_action` varchar(100) DEFAULT NULL COMMENT 'e.g., Downloaded, Accessed API, Ran Analysis' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- Dumping data for table `dsps_tbl_datasource_used` +-- + +INSERT INTO `dsps_tbl_datasource_used` (`pkdspsdspused_id`, `dspsdspused_reg_datetime`, `dspsdspused_mod_datetime`, `dspsdspused_reg_by`, `fkdspsdsused_id`, `fkisp_id_of`, `dspsdspused_datetime`, `dspsdspused_action`) VALUES +(1, '2025-08-09 13:08:06', '2025-08-09 13:08:06', NULL, 10, 3, '2025-08-09 13:08:06', 'Downloaded'), +(2, '2025-08-09 13:09:06', '2025-08-09 13:09:06', NULL, 9, 3, '2025-08-09 13:09:06', 'Downloaded'), +(3, '2025-08-09 14:34:08', '2025-08-09 14:34:08', NULL, 10, 3, '2025-08-09 14:34:08', 'Downloaded'), +(4, '2025-08-09 14:34:15', '2025-08-09 14:34:15', NULL, 10, 3, '2025-08-09 14:34:15', 'Downloaded'), +(5, '2025-08-30 14:30:39', '2025-08-30 14:30:39', NULL, 10, 104, '2025-08-30 14:30:39', 'Downloaded'); + +-- -------------------------------------------------------- + +-- +-- Table structure for table `dsps_tbl_dspsabout` +-- + +CREATE TABLE `dsps_tbl_dspsabout` ( + `pkdspsabout_id` int(11) NOT NULL, + `dspsabout_reg_datetime` datetime DEFAULT current_timestamp(), + `dspsabout_mod_datetime` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `dspsabout_reg_by` int(11) DEFAULT NULL COMMENT 'FK to ist_tbl_users', + `fkisp_id_of` int(11) NOT NULL COMMENT 'FK to ist_tbl_people (who created/modified this about entry)', + `dspsabout_title_en` varchar(255) NOT NULL, + `dspsabout_description` text DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- Dumping data for table `dsps_tbl_dspsabout` +-- + +INSERT INTO `dsps_tbl_dspsabout` (`pkdspsabout_id`, `dspsabout_reg_datetime`, `dspsabout_mod_datetime`, `dspsabout_reg_by`, `fkisp_id_of`, `dspsabout_title_en`, `dspsabout_description`) VALUES +(1, '2025-07-19 16:35:41', '2025-07-19 16:35:41', 1, 1, 'Our Vision', 'To be a leading institution in public health, fostering a healthier and more resilient community through evidence-based practices and collaborative data sharing.'), +(2, '2025-07-19 16:35:41', '2025-07-19 16:35:41', 1, 1, 'Our Mission', 'To protect and promote the health of the population through scientific research, education, and effective public health interventions, facilitated by accessible and secure data.'), +(3, '2025-07-19 16:35:41', '2025-07-19 16:35:41', 1, 1, 'Our Goals', '1. Enhance data accessibility for public health research. 2. Promote data-driven decision-making. 3. Strengthen collaboration among health stakeholders. 4. Ensure data security and privacy.'), +(4, '2025-07-19 16:35:41', '2025-07-19 16:35:41', 1, 1, 'About NIPH', 'The National Institute of Public Health (NIPH) is a governmental institution under the Ministry of Health, Cambodia. Established to conduct research, provide training, and offer public health services, NIPH plays a crucial role in improving the health status of the Cambodian population. This data sharing platform is an initiative to further our mission by enabling secure and efficient data exchange.'); + +-- -------------------------------------------------------- + +-- +-- Table structure for table `dsps_tbl_dspscategory` +-- + +CREATE TABLE `dsps_tbl_dspscategory` ( + `pkdspscate_id` int(11) NOT NULL, + `dspscate_reg_datetime` datetime DEFAULT current_timestamp(), + `dspscate_mod_datetime` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `dspscate_reg_by` int(11) DEFAULT NULL COMMENT 'FK to ist_tbl_users', + `dspscate_title_en` varchar(255) NOT NULL, + `dspscate_details` text DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- Dumping data for table `dsps_tbl_dspscategory` +-- + +INSERT INTO `dsps_tbl_dspscategory` (`pkdspscate_id`, `dspscate_reg_datetime`, `dspscate_mod_datetime`, `dspscate_reg_by`, `dspscate_title_en`, `dspscate_details`) VALUES +(1, '2025-07-12 16:54:58', '2025-07-19 14:50:31', NULL, 'Public Health', 'Data related to public health, diseases, and demographics.'), +(2, '2025-07-12 16:54:58', '2025-07-19 14:50:38', NULL, 'Education Statistics', 'Statistical data on schools, students, and educational outcomes.'), +(3, '2025-07-12 16:54:58', '2025-07-19 14:50:41', NULL, 'Environmental Data', 'Data concerning climate, pollution, and natural resources.'); + +-- -------------------------------------------------------- + +-- +-- Table structure for table `dsps_tbl_dspsfaq` +-- + +CREATE TABLE `dsps_tbl_dspsfaq` ( + `pkdspsfaq_id` int(11) NOT NULL, + `dspsfaq_reg_datetime` datetime DEFAULT current_timestamp(), + `dspsfaq_mod_datetime` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `dspsfaq_reg_by` int(11) DEFAULT NULL COMMENT 'FK to ist_tbl_users', + `fkisp_id_of` int(11) NOT NULL COMMENT 'FK to ist_tbl_people (who created/modified this FAQ)', + `dspsfaq_title_en` varchar(255) NOT NULL COMMENT 'Question', + `dspsfaq_description` text NOT NULL COMMENT 'Answer' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- Dumping data for table `dsps_tbl_dspsfaq` +-- + +INSERT INTO `dsps_tbl_dspsfaq` (`pkdspsfaq_id`, `dspsfaq_reg_datetime`, `dspsfaq_mod_datetime`, `dspsfaq_reg_by`, `fkisp_id_of`, `dspsfaq_title_en`, `dspsfaq_description`) VALUES +(1, '2025-07-19 16:38:29', '2025-07-26 22:23:24', 1, 1, 'What is the NIPH Data Sharing Platform?', 'The NIPH Data Sharing Platform (DSP) is a secure online portal designed to facilitate the sharing and access of public health data among authorized researchers, policymakers, and the public. It aims to promote data-driven decision-making and collaborative research.'), +(2, '2025-07-19 16:38:29', '2025-07-19 16:38:29', 1, 1, 'How can I request access to data?', 'Data Users can browse available data sources and submit a formal request for access through the platform. The request will be reviewed by the respective Data Owner, who will approve or deny access based on the data\'s sensitivity and the user\'s justification.'), +(3, '2025-07-19 16:38:29', '2025-07-19 16:38:29', 1, 1, 'What types of data are available?', 'The platform hosts various types of public health data, including but not limited to epidemiological surveillance data, survey results, laboratory data, and research findings. Data formats may include CSV, JSON, PDF, and potentially API access.'), +(4, '2025-07-19 16:38:29', '2025-07-19 16:38:29', 1, 1, 'Is my data secure on this platform?', 'Yes, data security and privacy are paramount. The platform employs robust security measures, including secure authentication, role-based access control, and data encryption. All sensitive data is handled in compliance with national data protection regulations.'), +(5, '2025-07-19 16:38:29', '2025-07-19 16:38:29', 1, 1, 'How can I contribute my data?', 'If you are a Data Owner (e.g., a researcher or institution with relevant public health data), you can register on the platform and use the \"Manage My Data Sources\" section to upload and describe your datasets. All contributions are subject to review by DAC Staff.'); + +-- -------------------------------------------------------- + +-- +-- Table structure for table `dsps_tbl_dspsslide` +-- + +CREATE TABLE `dsps_tbl_dspsslide` ( + `pkdspsslide_id` int(11) NOT NULL, + `dspsslide_reg_datetime` datetime DEFAULT current_timestamp(), + `dspsslide_mod_datetime` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `dspsslide_reg_by` int(11) DEFAULT NULL COMMENT 'FK to ist_tbl_users', + `fkisp_id_of` int(11) NOT NULL COMMENT 'FK to ist_tbl_people (who created/modified this slide)', + `dspsslide_title_en` varchar(255) NOT NULL, + `dspsslide_description` text DEFAULT NULL, + `dspsslide_photoname` varchar(255) NOT NULL COMMENT 'File path/name of the slide image' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- Dumping data for table `dsps_tbl_dspsslide` +-- + +INSERT INTO `dsps_tbl_dspsslide` (`pkdspsslide_id`, `dspsslide_reg_datetime`, `dspsslide_mod_datetime`, `dspsslide_reg_by`, `fkisp_id_of`, `dspsslide_title_en`, `dspsslide_description`, `dspsslide_photoname`) VALUES +(1, '2025-07-20 10:24:56', '2025-08-12 10:01:29', 1, 1, 'Welcome to NIPH Data Sharing Platform', 'Your central hub for public health data and collaborative research.', 'slide_689aae8993e8c.jpg'), +(2, '2025-07-20 10:24:56', '2025-07-20 10:29:44', 1, 1, 'Explore Diverse Datasets', 'Access a wide range of epidemiological, clinical, and environmental health data.', 'slide_68f5da6a43a8d.jpg'), +(3, '2025-07-20 10:24:56', '2025-07-20 10:29:46', 1, 1, 'Empowering Data-Driven Decisions', 'Facilitating informed policy-making and public health interventions.', 'slide_68f5da6a43a8a.jpg'); + +-- -------------------------------------------------------- + +-- +-- Table structure for table `dsps_tbl_feedback` +-- + +CREATE TABLE `dsps_tbl_feedback` ( + `pkdspsfb_id` int(11) NOT NULL, + `dspsfb_reg_datetime` datetime DEFAULT current_timestamp(), + `dspsfb_res_datetime` datetime DEFAULT NULL COMMENT 'Response datetime', + `dspsfb_res_by` int(11) DEFAULT NULL COMMENT 'FK to ist_tbl_users (who responded)', + `dspsfb_client_ip` varchar(45) DEFAULT NULL COMMENT 'IP of the feedback submitter', + `dspsfb_name` varchar(255) NOT NULL, + `dspsfb_email` varchar(255) DEFAULT NULL, + `dspsfb_body_text` text NOT NULL, + `dspsfb_respond_text` text DEFAULT NULL COMMENT 'Response from DAC staff/admin', + `dspsfb_status` enum('New','In Progress','Resolved','Archived') NOT NULL DEFAULT 'New' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `dsps_tbl_social` +-- + +CREATE TABLE `dsps_tbl_social` ( + `pkdspssocial_id` int(11) NOT NULL, + `dspssocial_reg_datetime` datetime DEFAULT current_timestamp(), + `dspssocial_mod_datetime` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `dspssocial_reg_by` int(11) DEFAULT NULL COMMENT 'FK to ist_tbl_users', + `dspssocial_name` varchar(100) NOT NULL COMMENT 'e.g., Facebook, YouTube, Telegram', + `dspssocial_link` varchar(255) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `dsps_tbl_typedatasource` +-- + +CREATE TABLE `dsps_tbl_typedatasource` ( + `pkdspstds_id` int(11) NOT NULL, + `dspstds_reg_datetime` datetime DEFAULT current_timestamp(), + `dspstds_mod_datetime` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `dspstds_reg_by` int(11) DEFAULT NULL COMMENT 'FK to ist_tbl_users', + `dspstds_name_en` varchar(100) NOT NULL, + `dspstds_name_kh` varchar(100) DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- Dumping data for table `dsps_tbl_typedatasource` +-- + +INSERT INTO `dsps_tbl_typedatasource` (`pkdspstds_id`, `dspstds_reg_datetime`, `dspstds_mod_datetime`, `dspstds_reg_by`, `dspstds_name_en`, `dspstds_name_kh`) VALUES +(1, '2025-07-12 16:54:58', '2025-07-12 16:54:58', NULL, 'CSV', 'ស៊ីអេសវី'), +(2, '2025-07-12 16:54:58', '2025-07-12 16:54:58', NULL, 'JSON', 'ជេសអិន'), +(3, '2025-07-12 16:54:58', '2025-07-12 16:54:58', NULL, 'API', 'អេភីអាយ'), +(4, '2025-07-12 16:54:58', '2025-07-12 16:54:58', NULL, 'PDF', 'ភីឌីអេហ្វ'); + +-- -------------------------------------------------------- + +-- +-- Table structure for table `ist_tbl_people` +-- + +CREATE TABLE `ist_tbl_people` ( + `pkisp_id` int(11) NOT NULL, + `isp_reg_datetime` datetime DEFAULT current_timestamp(), + `isp_mod_datetime` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `isp_regby_id` int(11) DEFAULT NULL COMMENT 'FK to ist_tbl_users if registered by another user, or NULL if self-registered', + `isp_idcard` varchar(50) DEFAULT NULL, + `isp_firstname_en` varchar(100) NOT NULL, + `isp_lastname_en` varchar(100) NOT NULL, + `isp_sex` enum('Male','Female','Other') NOT NULL, + `isp_dob` date NOT NULL, + `isp_pob` varchar(255) DEFAULT NULL COMMENT 'Place of Birth', + `isp_nationality` varchar(100) DEFAULT 'Cambodian', + `isp_marital_status` enum('Single','Married','Divorced','Widowed') DEFAULT 'Single', + `isp_phone_number` varchar(20) DEFAULT NULL, + `isp_email` varchar(255) DEFAULT NULL, + `isp_telegram` varchar(255) DEFAULT NULL, + `isp_note` text DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- Dumping data for table `ist_tbl_people` +-- + +INSERT INTO `ist_tbl_people` (`pkisp_id`, `isp_reg_datetime`, `isp_mod_datetime`, `isp_regby_id`, `isp_idcard`, `isp_firstname_en`, `isp_lastname_en`, `isp_sex`, `isp_dob`, `isp_pob`, `isp_nationality`, `isp_marital_status`, `isp_phone_number`, `isp_email`, `isp_telegram`, `isp_note`) VALUES +(1, '2025-07-12 16:16:55', '2025-07-12 16:16:55', NULL, '123456789', 'Admin', 'User', 'Male', '1990-01-01', NULL, 'Cambodian', 'Single', '0123456789', 'admin@example.com', NULL, NULL), +(2, '2025-07-12 16:16:55', '2025-07-12 16:16:55', NULL, '987654321', 'Data', 'Owner', 'Female', '1985-05-10', NULL, 'Cambodian', 'Single', '0987654321', 'owner@example.com', NULL, NULL), +(3, '2025-07-12 16:16:55', '2025-07-12 16:16:55', NULL, '112233445', 'Data', 'User', 'Male', '1992-11-20', NULL, 'Cambodian', 'Single', '0112233445', 'user@example.com', NULL, NULL), +(101, '2025-07-20 12:43:02', '2025-07-20 12:43:02', NULL, '1234567890', 'Mock', 'Owner', 'Male', '1980-01-01', NULL, 'Cambodian', 'Single', '111222333', 'mock.owner@example.com', NULL, NULL), +(102, '2025-08-30 08:34:25', '2025-08-30 08:34:25', NULL, '11', '11', '111', 'Male', '2025-08-30', NULL, 'Cambodian', 'Single', '', '', NULL, NULL), +(103, '2025-08-30 08:37:51', '2025-08-30 08:37:51', NULL, 'N001', 'Pisey', 'Um', 'Female', '2025-08-22', NULL, 'Cambodian', 'Single', '012', 'pisey@gmail.com', NULL, NULL), +(104, '2025-08-30 09:07:13', '2025-08-30 09:07:13', NULL, 'N002', 'sp', 'ch', 'Male', '2025-08-30', NULL, 'Cambodian', 'Single', '092', 'sp@gmail.com', NULL, NULL), +(105, '2025-08-30 10:46:55', '2025-08-30 10:46:55', NULL, 'p001', 'pp', 'ppp', 'Male', '2025-08-30', NULL, 'Cambodian', 'Single', '123', 'pp@gmail.com', NULL, NULL); + +-- -------------------------------------------------------- + +-- +-- Table structure for table `ist_tbl_users` +-- + +CREATE TABLE `ist_tbl_users` ( + `pkisu_id` int(11) NOT NULL, + `isu_reg_datetime` datetime DEFAULT current_timestamp(), + `isu_mod_datetime` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `isu_regby_id` int(11) DEFAULT NULL COMMENT 'FK to pkisu_id if registered by another user, or NULL if self-registered', + `fkisp_id_of` int(11) NOT NULL COMMENT 'FK to ist_tbl_people', + `isu_name` varchar(100) NOT NULL COMMENT 'Username', + `isu_password` varchar(255) NOT NULL COMMENT 'Hashed password', + `isu_status` enum('DAC Staff','Data Contributor','Data Owner','Data User','Inactive') NOT NULL DEFAULT 'Data User', + `isu_can_run_r` tinyint(1) NOT NULL DEFAULT 0 COMMENT '1 if user may run R/Jupyter integrations' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- Dumping data for table `ist_tbl_users` +-- + +INSERT INTO `ist_tbl_users` (`pkisu_id`, `isu_reg_datetime`, `isu_mod_datetime`, `isu_regby_id`, `fkisp_id_of`, `isu_name`, `isu_password`, `isu_status`, `isu_can_run_r`) VALUES +(1, '2025-07-12 16:16:55', '2025-07-20 10:48:49', 1, 1, 'admin', '$2y$10$2h/CFUM6D8d0nFSOgUQebOL/ow5pyTAsDRcqZAv0XrkAmyhVs2fIe', 'DAC Staff', 1), +(2, '2025-07-12 16:16:55', '2025-07-20 12:05:18', 1, 2, 'owner', '$2y$10$2h/CFUM6D8d0nFSOgUQebOL/ow5pyTAsDRcqZAv0XrkAmyhVs2fIe', 'Data Owner', 1), +(3, '2025-07-12 16:16:55', '2025-08-11 08:41:57', 1, 3, 'user', '$2y$10$2h/CFUM6D8d0nFSOgUQebOL/ow5pyTAsDRcqZAv0XrkAmyhVs2fIe', 'Data User', 0), +(203, '2025-07-12 16:16:55', '2025-08-11 08:41:57', 3, 101, 'mockup', '$2y$10$2h/CFUM6D8d0nFSOgUQebOL/ow5pyTAsDRcqZAv0XrkAmyhVs2fIe', 'Data User', 0), +(204, '2025-08-30 08:34:25', '2025-08-30 08:34:25', NULL, 102, 'ttt', '$2y$10$aOBNcCE9b1Jh.c.g7tP5gOSoD6RKujzPVV3AMGJH02jm.Uom0.GxS', 'Data User', 0), +(205, '2025-08-30 08:37:51', '2025-08-30 09:10:33', 1, 103, 'pisey', '$2y$10$DYdxGJLZ3XFJWZI.Tcq2IO18DOhXw2KBwnzAXZ3SD8zD0Bw/vtyOO', 'Data Owner', 1), +(206, '2025-08-30 09:07:14', '2025-08-30 10:37:43', NULL, 104, 'sp', '$2y$10$yy9L1fK5Il2e3sSw03pyTukxGRRxU5bxc4Zp09fSZ4GvfhkdcZy4W', 'Data Contributor', 0), +(207, '2025-08-30 10:46:56', '2025-08-30 10:46:56', NULL, 105, 'pp', '$2y$10$JBTBNdWoifyQ3kodJjJfc.c8CQlbVNKVvfJ.lSKiBcVU5W8PkyVom', 'Data User', 0); + +-- +-- Indexes for dumped tables +-- + +-- +-- Indexes for table `dsps_tbl_announcement` +-- +ALTER TABLE `dsps_tbl_announcement` + ADD PRIMARY KEY (`pkdspsann_id`), + ADD KEY `dspsann_reg_by` (`dspsann_reg_by`), + ADD KEY `idx_dspsann_status` (`dspsann_status`); + +-- +-- Indexes for table `dsps_tbl_anonymous` +-- +ALTER TABLE `dsps_tbl_anonymous` + ADD PRIMARY KEY (`pkdspsano_id`), + ADD KEY `fkdspsds_id` (`fkdspsds_id`); + +-- +-- Indexes for table `dsps_tbl_datasource` +-- +ALTER TABLE `dsps_tbl_datasource` + ADD PRIMARY KEY (`pkdspsds_id`), + ADD KEY `dspsds_reg_by` (`dspsds_reg_by`), + ADD KEY `fkdspstds_id` (`fkdspstds_id`), + ADD KEY `fkdspscate_id` (`fkdspscate_id`), + ADD KEY `fkisp_id_of` (`fkisp_id_of`), + ADD KEY `idx_dspsds_status` (`dspsds_status`); + +-- +-- Indexes for table `dsps_tbl_datasource_permission` +-- +ALTER TABLE `dsps_tbl_datasource_permission` + ADD PRIMARY KEY (`pkdspsdsp_id`), + ADD UNIQUE KEY `fkdspsds_id` (`fkdspsds_id`,`fkisp_id_of`), + ADD KEY `dspsdsp_reg_by` (`dspsdsp_reg_by`), + ADD KEY `fkisp_id_of` (`fkisp_id_of`), + ADD KEY `idx_dspsdsp_status` (`dspsdsp_status`); + +-- +-- Indexes for table `dsps_tbl_datasource_used` +-- +ALTER TABLE `dsps_tbl_datasource_used` + ADD PRIMARY KEY (`pkdspsdspused_id`), + ADD KEY `dspsdspused_reg_by` (`dspsdspused_reg_by`), + ADD KEY `fkdspsdsused_id` (`fkdspsdsused_id`), + ADD KEY `fkisp_id_of` (`fkisp_id_of`); + +-- +-- Indexes for table `dsps_tbl_dspsabout` +-- +ALTER TABLE `dsps_tbl_dspsabout` + ADD PRIMARY KEY (`pkdspsabout_id`), + ADD KEY `fkisp_id_of` (`fkisp_id_of`), + ADD KEY `dspsabout_reg_by` (`dspsabout_reg_by`), + ADD KEY `idx_dspsabout_title` (`dspsabout_title_en`); + +-- +-- Indexes for table `dsps_tbl_dspscategory` +-- +ALTER TABLE `dsps_tbl_dspscategory` + ADD PRIMARY KEY (`pkdspscate_id`), + ADD UNIQUE KEY `dspscate_title_en` (`dspscate_title_en`), + ADD KEY `dspscate_reg_by` (`dspscate_reg_by`); + +-- +-- Indexes for table `dsps_tbl_dspsfaq` +-- +ALTER TABLE `dsps_tbl_dspsfaq` + ADD PRIMARY KEY (`pkdspsfaq_id`), + ADD KEY `fkisp_id_of` (`fkisp_id_of`), + ADD KEY `dspsfaq_reg_by` (`dspsfaq_reg_by`); + +-- +-- Indexes for table `dsps_tbl_dspsslide` +-- +ALTER TABLE `dsps_tbl_dspsslide` + ADD PRIMARY KEY (`pkdspsslide_id`), + ADD KEY `fkisp_id_of` (`fkisp_id_of`), + ADD KEY `dspsslide_reg_by` (`dspsslide_reg_by`); + +-- +-- Indexes for table `dsps_tbl_feedback` +-- +ALTER TABLE `dsps_tbl_feedback` + ADD PRIMARY KEY (`pkdspsfb_id`), + ADD KEY `dspsfb_res_by` (`dspsfb_res_by`), + ADD KEY `idx_dspsfb_status` (`dspsfb_status`); + +-- +-- Indexes for table `dsps_tbl_social` +-- +ALTER TABLE `dsps_tbl_social` + ADD PRIMARY KEY (`pkdspssocial_id`), + ADD UNIQUE KEY `dspssocial_name` (`dspssocial_name`), + ADD KEY `dspssocial_reg_by` (`dspssocial_reg_by`); + +-- +-- Indexes for table `dsps_tbl_typedatasource` +-- +ALTER TABLE `dsps_tbl_typedatasource` + ADD PRIMARY KEY (`pkdspstds_id`), + ADD UNIQUE KEY `dspstds_name_en` (`dspstds_name_en`), + ADD UNIQUE KEY `dspstds_name_kh` (`dspstds_name_kh`), + ADD KEY `dspstds_reg_by` (`dspstds_reg_by`); + +-- +-- Indexes for table `ist_tbl_people` +-- +ALTER TABLE `ist_tbl_people` + ADD PRIMARY KEY (`pkisp_id`), + ADD UNIQUE KEY `isp_idcard` (`isp_idcard`), + ADD UNIQUE KEY `isp_phone_number` (`isp_phone_number`), + ADD UNIQUE KEY `isp_email` (`isp_email`), + ADD KEY `idx_isp_idcard` (`isp_idcard`), + ADD KEY `idx_isp_name` (`isp_firstname_en`,`isp_lastname_en`); + +-- +-- Indexes for table `ist_tbl_users` +-- +ALTER TABLE `ist_tbl_users` + ADD PRIMARY KEY (`pkisu_id`), + ADD UNIQUE KEY `fkisp_id_of` (`fkisp_id_of`), + ADD UNIQUE KEY `isu_name` (`isu_name`), + ADD KEY `isu_regby_id` (`isu_regby_id`), + ADD KEY `idx_isu_name` (`isu_name`), + ADD KEY `idx_isu_status` (`isu_status`); + +-- +-- Table structure for table `dsp_oauth_clients` +-- + +DROP TABLE IF EXISTS `dsp_oauth_clients`; +CREATE TABLE `dsp_oauth_clients` ( + `client_id` varchar(128) NOT NULL, + `client_name` varchar(255) NOT NULL, + `client_secret_hash` varchar(255) DEFAULT NULL, + `redirect_uris` text NOT NULL, + `allowed_scopes` varchar(255) DEFAULT NULL, + `is_confidential` tinyint(1) NOT NULL DEFAULT 1, + `is_revoked` tinyint(1) NOT NULL DEFAULT 0, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`client_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- Table structure for table `dsp_oauth_auth_codes` +-- + +DROP TABLE IF EXISTS `dsp_oauth_auth_codes`; +CREATE TABLE `dsp_oauth_auth_codes` ( + `code_hash` char(64) NOT NULL, + `client_id` varchar(128) NOT NULL, + `person_id` int(11) NOT NULL, + `scope` varchar(255) DEFAULT NULL, + `redirect_uri` varchar(2000) NOT NULL, + `expires_at` datetime NOT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`code_hash`), + KEY `idx_oauth_auth_client` (`client_id`), + KEY `idx_oauth_auth_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- Table structure for table `dsp_oauth_access_tokens` +-- + +DROP TABLE IF EXISTS `dsp_oauth_access_tokens`; +CREATE TABLE `dsp_oauth_access_tokens` ( + `token_hash` char(64) NOT NULL, + `client_id` varchar(128) NOT NULL, + `person_id` int(11) NOT NULL, + `scope` varchar(255) DEFAULT NULL, + `expires_at` datetime NOT NULL, + `refresh_token_hash` char(64) DEFAULT NULL, + `refresh_expires_at` datetime DEFAULT NULL, + `is_revoked` tinyint(1) NOT NULL DEFAULT 0, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `last_used_at` datetime DEFAULT NULL, + `revoked_at` datetime DEFAULT NULL, + PRIMARY KEY (`token_hash`), + KEY `idx_oauth_access_client` (`client_id`), + KEY `idx_oauth_access_person` (`person_id`), + KEY `idx_oauth_access_refresh` (`refresh_token_hash`), + KEY `idx_oauth_access_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- AUTO_INCREMENT for dumped tables +-- + +-- +-- AUTO_INCREMENT for table `dsps_tbl_announcement` +-- +ALTER TABLE `dsps_tbl_announcement` + MODIFY `pkdspsann_id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=6; + +-- +-- AUTO_INCREMENT for table `dsps_tbl_anonymous` +-- +ALTER TABLE `dsps_tbl_anonymous` + MODIFY `pkdspsano_id` int(11) NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `dsps_tbl_datasource` +-- +ALTER TABLE `dsps_tbl_datasource` + MODIFY `pkdspsds_id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=13; + +-- +-- AUTO_INCREMENT for table `dsps_tbl_datasource_permission` +-- +ALTER TABLE `dsps_tbl_datasource_permission` + MODIFY `pkdspsdsp_id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=8; + +-- +-- AUTO_INCREMENT for table `dsps_tbl_datasource_used` +-- +ALTER TABLE `dsps_tbl_datasource_used` + MODIFY `pkdspsdspused_id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=6; + +-- +-- AUTO_INCREMENT for table `dsps_tbl_dspsabout` +-- +ALTER TABLE `dsps_tbl_dspsabout` + MODIFY `pkdspsabout_id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=5; + +-- +-- AUTO_INCREMENT for table `dsps_tbl_dspscategory` +-- +ALTER TABLE `dsps_tbl_dspscategory` + MODIFY `pkdspscate_id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=4; + +-- +-- AUTO_INCREMENT for table `dsps_tbl_dspsfaq` +-- +ALTER TABLE `dsps_tbl_dspsfaq` + MODIFY `pkdspsfaq_id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=6; + +-- +-- AUTO_INCREMENT for table `dsps_tbl_dspsslide` +-- +ALTER TABLE `dsps_tbl_dspsslide` + MODIFY `pkdspsslide_id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=4; + +-- +-- AUTO_INCREMENT for table `dsps_tbl_feedback` +-- +ALTER TABLE `dsps_tbl_feedback` + MODIFY `pkdspsfb_id` int(11) NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `dsps_tbl_social` +-- +ALTER TABLE `dsps_tbl_social` + MODIFY `pkdspssocial_id` int(11) NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `dsps_tbl_typedatasource` +-- +ALTER TABLE `dsps_tbl_typedatasource` + MODIFY `pkdspstds_id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=6; + +-- +-- AUTO_INCREMENT for table `ist_tbl_people` +-- +ALTER TABLE `ist_tbl_people` + MODIFY `pkisp_id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=106; + +-- +-- AUTO_INCREMENT for table `ist_tbl_users` +-- +ALTER TABLE `ist_tbl_users` + MODIFY `pkisu_id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=208; + +-- +-- Constraints for dumped tables +-- + +-- +-- Constraints for table `dsps_tbl_announcement` +-- +ALTER TABLE `dsps_tbl_announcement` + ADD CONSTRAINT `dsps_tbl_announcement_ibfk_1` FOREIGN KEY (`dspsann_reg_by`) REFERENCES `ist_tbl_users` (`pkisu_id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- +-- Constraints for table `dsps_tbl_anonymous` +-- +ALTER TABLE `dsps_tbl_anonymous` + ADD CONSTRAINT `dsps_tbl_anonymous_ibfk_1` FOREIGN KEY (`fkdspsds_id`) REFERENCES `dsps_tbl_datasource` (`pkdspsds_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- +-- Constraints for table `dsps_tbl_datasource` +-- +ALTER TABLE `dsps_tbl_datasource` + ADD CONSTRAINT `dsps_tbl_datasource_ibfk_1` FOREIGN KEY (`dspsds_reg_by`) REFERENCES `ist_tbl_users` (`pkisu_id`) ON DELETE SET NULL ON UPDATE CASCADE, + ADD CONSTRAINT `dsps_tbl_datasource_ibfk_2` FOREIGN KEY (`fkdspstds_id`) REFERENCES `dsps_tbl_typedatasource` (`pkdspstds_id`) ON UPDATE CASCADE, + ADD CONSTRAINT `dsps_tbl_datasource_ibfk_3` FOREIGN KEY (`fkdspscate_id`) REFERENCES `dsps_tbl_dspscategory` (`pkdspscate_id`) ON UPDATE CASCADE, + ADD CONSTRAINT `dsps_tbl_datasource_ibfk_4` FOREIGN KEY (`fkisp_id_of`) REFERENCES `ist_tbl_people` (`pkisp_id`) ON UPDATE CASCADE; + +-- +-- Constraints for table `dsps_tbl_datasource_permission` +-- +ALTER TABLE `dsps_tbl_datasource_permission` + ADD CONSTRAINT `dsps_tbl_datasource_permission_ibfk_1` FOREIGN KEY (`dspsdsp_reg_by`) REFERENCES `ist_tbl_users` (`pkisu_id`) ON DELETE SET NULL ON UPDATE CASCADE, + ADD CONSTRAINT `dsps_tbl_datasource_permission_ibfk_2` FOREIGN KEY (`fkdspsds_id`) REFERENCES `dsps_tbl_datasource` (`pkdspsds_id`) ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT `dsps_tbl_datasource_permission_ibfk_3` FOREIGN KEY (`fkisp_id_of`) REFERENCES `ist_tbl_people` (`pkisp_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- +-- Constraints for table `dsps_tbl_datasource_used` +-- +ALTER TABLE `dsps_tbl_datasource_used` + ADD CONSTRAINT `dsps_tbl_datasource_used_ibfk_1` FOREIGN KEY (`dspsdspused_reg_by`) REFERENCES `ist_tbl_users` (`pkisu_id`) ON DELETE SET NULL ON UPDATE CASCADE, + ADD CONSTRAINT `dsps_tbl_datasource_used_ibfk_2` FOREIGN KEY (`fkdspsdsused_id`) REFERENCES `dsps_tbl_datasource` (`pkdspsds_id`) ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT `dsps_tbl_datasource_used_ibfk_3` FOREIGN KEY (`fkisp_id_of`) REFERENCES `ist_tbl_people` (`pkisp_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- +-- Constraints for table `dsps_tbl_dspsabout` +-- +ALTER TABLE `dsps_tbl_dspsabout` + ADD CONSTRAINT `dsps_tbl_dspsabout_ibfk_1` FOREIGN KEY (`fkisp_id_of`) REFERENCES `ist_tbl_people` (`pkisp_id`) ON UPDATE CASCADE, + ADD CONSTRAINT `dsps_tbl_dspsabout_ibfk_2` FOREIGN KEY (`dspsabout_reg_by`) REFERENCES `ist_tbl_users` (`pkisu_id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- +-- Constraints for table `dsps_tbl_dspscategory` +-- +ALTER TABLE `dsps_tbl_dspscategory` + ADD CONSTRAINT `dsps_tbl_dspscategory_ibfk_1` FOREIGN KEY (`dspscate_reg_by`) REFERENCES `ist_tbl_users` (`pkisu_id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- +-- Constraints for table `dsps_tbl_dspsfaq` +-- +ALTER TABLE `dsps_tbl_dspsfaq` + ADD CONSTRAINT `dsps_tbl_dspsfaq_ibfk_1` FOREIGN KEY (`fkisp_id_of`) REFERENCES `ist_tbl_people` (`pkisp_id`) ON UPDATE CASCADE, + ADD CONSTRAINT `dsps_tbl_dspsfaq_ibfk_2` FOREIGN KEY (`dspsfaq_reg_by`) REFERENCES `ist_tbl_users` (`pkisu_id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- +-- Constraints for table `dsps_tbl_dspsslide` +-- +ALTER TABLE `dsps_tbl_dspsslide` + ADD CONSTRAINT `dsps_tbl_dspsslide_ibfk_1` FOREIGN KEY (`fkisp_id_of`) REFERENCES `ist_tbl_people` (`pkisp_id`) ON UPDATE CASCADE, + ADD CONSTRAINT `dsps_tbl_dspsslide_ibfk_2` FOREIGN KEY (`dspsslide_reg_by`) REFERENCES `ist_tbl_users` (`pkisu_id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- +-- Constraints for table `dsps_tbl_feedback` +-- +ALTER TABLE `dsps_tbl_feedback` + ADD CONSTRAINT `dsps_tbl_feedback_ibfk_1` FOREIGN KEY (`dspsfb_res_by`) REFERENCES `ist_tbl_users` (`pkisu_id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- +-- Constraints for table `dsps_tbl_social` +-- +ALTER TABLE `dsps_tbl_social` + ADD CONSTRAINT `dsps_tbl_social_ibfk_1` FOREIGN KEY (`dspssocial_reg_by`) REFERENCES `ist_tbl_users` (`pkisu_id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- +-- Constraints for table `dsps_tbl_typedatasource` +-- +ALTER TABLE `dsps_tbl_typedatasource` + ADD CONSTRAINT `dsps_tbl_typedatasource_ibfk_1` FOREIGN KEY (`dspstds_reg_by`) REFERENCES `ist_tbl_users` (`pkisu_id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- +-- Constraints for table `dsp_oauth_auth_codes` +-- +ALTER TABLE `dsp_oauth_auth_codes` + ADD CONSTRAINT `dsp_oauth_auth_codes_ibfk_1` FOREIGN KEY (`client_id`) REFERENCES `dsp_oauth_clients` (`client_id`) ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT `dsp_oauth_auth_codes_ibfk_2` FOREIGN KEY (`person_id`) REFERENCES `ist_tbl_people` (`pkisp_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- +-- Constraints for table `dsp_oauth_access_tokens` +-- +ALTER TABLE `dsp_oauth_access_tokens` + ADD CONSTRAINT `dsp_oauth_access_tokens_ibfk_1` FOREIGN KEY (`client_id`) REFERENCES `dsp_oauth_clients` (`client_id`) ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT `dsp_oauth_access_tokens_ibfk_2` FOREIGN KEY (`person_id`) REFERENCES `ist_tbl_people` (`pkisp_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- +-- Constraints for table `ist_tbl_users` +-- +ALTER TABLE `ist_tbl_users` + ADD CONSTRAINT `ist_tbl_users_ibfk_1` FOREIGN KEY (`fkisp_id_of`) REFERENCES `ist_tbl_people` (`pkisp_id`) ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT `ist_tbl_users_ibfk_2` FOREIGN KEY (`isu_regby_id`) REFERENCES `ist_tbl_users` (`pkisu_id`) ON DELETE SET NULL ON UPDATE CASCADE; +COMMIT; + +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3a9e145 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,78 @@ +version: '3.9' + +services: + app: + build: . + container_name: dsp_app + restart: unless-stopped + env_file: + - .env + ports: + - "4010:80" + - "8082:80" + environment: + DB_HOST: db + DB_PORT: 3306 + DB_NAME: niph_dsps + DB_USER: dsp_user + DB_PASS: dsp_pass + JUPYTER_EXTERNAL_URL: ${JUPYTER_EXTERNAL_URL:-} + JUPYTER_PORT: ${JUPYTER_PORT:-443} + DSP_APP_ORIGINS: ${DSP_APP_ORIGINS:-} + DSP_FRAME_ANCESTORS: ${DSP_FRAME_ANCESTORS:-} + volumes: + - ./:/var/www/html + depends_on: + - db + + db: + image: mysql:8.0 + container_name: dsp_db + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: rootpassword + MYSQL_DATABASE: niph_dsps + MYSQL_USER: dsp_user + MYSQL_PASSWORD: dsp_pass + ports: + - "3307:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./db/niph_dsps.sql:/docker-entrypoint-initdb.d/01-init.sql:ro + + phpmyadmin: + image: phpmyadmin:latest + container_name: dsp_phpmyadmin + restart: unless-stopped + ports: + - "8081:80" + environment: + PMA_HOST: db + PMA_USER: dsp_user + PMA_PASSWORD: dsp_pass + UPLOAD_LIMIT: 64M + depends_on: + - db + + jupyterhub: + build: + context: ./docker/jupyterhub + container_name: dsp_jupyterhub + restart: unless-stopped + env_file: + - .env + ports: + - "${JUPYTERHUB_PORT:-443}:8000" + - "8888:8000" + environment: + DSP_JH_NETWORK: dsp_default + DSP_APP_CONTAINER: dsp_app + DSP_WORKSPACE_ROOT: ${DSP_WORKSPACE_ROOT:-/var/www/html/uploads/jupyter_workspace} + volumes: + - ./uploads/jupyter_workspace:/var/www/html/uploads/jupyter_workspace + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - app + +volumes: + mysql_data: diff --git a/docker/app-entrypoint.sh b/docker/app-entrypoint.sh new file mode 100644 index 0000000..15a545c --- /dev/null +++ b/docker/app-entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +# Ensure uploads subdirectories exist and are writable +UPLOAD_ROOT="/var/www/html/uploads" +mkdir -p "$UPLOAD_ROOT/announcements" "$UPLOAD_ROOT/slides" "$UPLOAD_ROOT/datasources" + +# Relax permissions so Apache (www-data) can write when using bind mounts +chown -R www-data:www-data "$UPLOAD_ROOT" +chmod -R 775 "$UPLOAD_ROOT" + +exec apache2-foreground diff --git a/docker/custom.ini b/docker/custom.ini new file mode 100644 index 0000000..3156476 --- /dev/null +++ b/docker/custom.ini @@ -0,0 +1,2 @@ +upload_max_filesize=2048M +post_max_size=2048M diff --git a/docker/jupyter/jupyter_server_config.py b/docker/jupyter/jupyter_server_config.py new file mode 100644 index 0000000..0c2813a --- /dev/null +++ b/docker/jupyter/jupyter_server_config.py @@ -0,0 +1,45 @@ +"""Jupyter Server configuration for DSP docker stack.""" +from __future__ import annotations + +import os +import re + +c = get_config() # noqa: F821 - provided by Jupyter at runtime + +default_app_origins = [ + "http://localhost:8082", + "http://127.0.0.1:8082", +] +extra_app_origins = [ + value.rstrip("/") for value in os.getenv("DSP_APP_ORIGINS", "").split() if value +] +allowed_app_origins = list( + dict.fromkeys([origin.rstrip("/") for origin in default_app_origins + extra_app_origins]) +) + +if allowed_app_origins: + c.ServerApp.allow_origin = allowed_app_origins[0] + if len(allowed_app_origins) > 1: + escaped_origins = [re.escape(origin) for origin in allowed_app_origins] + pattern = "^(" + "|".join(escaped_origins) + ")$" + c.ServerApp.allow_origin_pat = pattern + +c.ServerApp.allow_remote_access = True +c.ServerApp.disable_check_xsrf = True + +default_frame_ancestors = [ + "'self'", + "http://localhost:8082", + "http://127.0.0.1:8082", +] +extra_frame_ancestors = [value for value in os.getenv("DSP_FRAME_ANCESTORS", "").split() if value] +frame_ancestors = " ".join( + dict.fromkeys(default_frame_ancestors + [origin.rstrip("/") for origin in extra_frame_ancestors]) +) + +c.ServerApp.tornado_settings = { + "headers": { + "Content-Security-Policy": f"frame-ancestors {frame_ancestors}", + "X-Frame-Options": "ALLOWALL", + } +} diff --git a/docker/jupyterhub/Dockerfile b/docker/jupyterhub/Dockerfile new file mode 100644 index 0000000..66ea215 --- /dev/null +++ b/docker/jupyterhub/Dockerfile @@ -0,0 +1,9 @@ +FROM jupyterhub/jupyterhub:4.1 + +# Install required Python packages and Docker CLI for pre-spawn syncing +RUN pip install --no-cache-dir oauthenticator dockerspawner jupyterhub-idle-culler && \ + apt-get update && \ + apt-get install -y --no-install-recommends docker.io && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +COPY jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py diff --git a/docker/jupyterhub/jupyterhub_config.py b/docker/jupyterhub/jupyterhub_config.py new file mode 100644 index 0000000..5ed29a2 --- /dev/null +++ b/docker/jupyterhub/jupyterhub_config.py @@ -0,0 +1,144 @@ +import logging +import os +import re +import subprocess +from typing import Dict, Optional + +from oauthenticator.generic import GenericOAuthenticator +from jupyterhub.auth import DummyAuthenticator +from dockerspawner import DockerSpawner + +c = get_config() + +# Authenticator selection (environment-driven) +c.Authenticator.enable_auth_state = False +auth_strategy = os.environ.get("JUPYTERHUB_AUTH_STRATEGY", "oauth").strip().lower() + +if auth_strategy == "dummy": + c.JupyterHub.authenticator_class = DummyAuthenticator + dummy_password = os.environ.get("JUPYTERHUB_DUMMY_PASSWORD") + if dummy_password: + c.DummyAuthenticator.password = dummy_password +else: + c.JupyterHub.authenticator_class = GenericOAuthenticator + c.GenericOAuthenticator.client_id = os.environ.get("DSP_OAUTH_CLIENT_ID", "") + c.GenericOAuthenticator.client_secret = os.environ.get("DSP_OAUTH_CLIENT_SECRET", "") + c.GenericOAuthenticator.authorize_url = os.environ.get("DSP_OAUTH_AUTHORIZE_URL", "") + c.GenericOAuthenticator.token_url = os.environ.get("DSP_OAUTH_TOKEN_URL", "") + c.GenericOAuthenticator.userdata_url = os.environ.get("DSP_OAUTH_USERINFO_URL", "") + c.GenericOAuthenticator.oauth_callback_url = os.environ.get("JUPYTERHUB_OAUTH_CALLBACK", "") + c.GenericOAuthenticator.scope = ["profile"] + c.GenericOAuthenticator.username_claim = "hub_username" + c.GenericOAuthenticator.username_key = "hub_username" + c.GenericOAuthenticator.auto_login = True + +# Explicitly acknowledge HTTP when running behind an external TLS terminator. +c.JupyterHub.confirm_no_ssl = True +c.Spawner.http_timeout = int(os.getenv("JUPYTERHUB_HTTP_TIMEOUT", "90")) +c.Spawner.start_timeout = int(os.getenv("JUPYTERHUB_START_TIMEOUT", "90")) + +def _env_bool(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +tornado_settings = getattr(c.JupyterHub, "tornado_settings", {}) +if isinstance(tornado_settings, dict): + merged_settings = tornado_settings.copy() +else: + merged_settings = {} + +default_frame_ancestors = ["'self'", "http://localhost:8082", "http://127.0.0.1:8082"] +app_origin_values = [value.rstrip("/") for value in os.getenv("DSP_APP_ORIGINS", "").split() if value] +extra_frame_ancestors = [value.rstrip("/") for value in os.getenv("DSP_FRAME_ANCESTORS", "").split() if value] +frame_ancestors = " ".join( + dict.fromkeys(default_frame_ancestors + app_origin_values + extra_frame_ancestors) +) + +header_settings = { + "Content-Security-Policy": f"frame-ancestors {frame_ancestors}", + "X-Frame-Options": "ALLOWALL", +} + +existing_headers = merged_settings.get("headers", {}) +existing_headers.update(header_settings) +merged_settings["headers"] = existing_headers + +external_url = os.getenv("JUPYTER_EXTERNAL_URL", "") +cookie_secure_default = external_url.startswith("https://") +merged_settings["cookie_options"] = { + "SameSite": "None", + "Secure": _env_bool("JUPYTERHUB_COOKIE_SECURE", cookie_secure_default), +} +c.JupyterHub.tornado_settings = merged_settings + +# Single-user server configuration +c.JupyterHub.spawner_class = DockerSpawner +c.DockerSpawner.image = os.environ.get("DSP_JH_IMAGE", "jupyter/minimal-notebook:python-3.11") +c.DockerSpawner.remove_containers = True +c.DockerSpawner.cmd = ["start-singleuser.sh"] +c.DockerSpawner.notebook_dir = "/home/jovyan/work" +c.DockerSpawner.network_name = os.environ.get("DSP_JH_NETWORK", "dsp_default") + + +def _workspace_volume(username: str) -> Dict[str, str]: + safe = re.sub(r"[^a-zA-Z0-9._-]+", "-", username) + host_root = os.environ.get("DSP_WORKSPACE_ROOT", "/var/www/html/uploads/jupyter_workspace") + volumes: Dict[str, str] = {f"{host_root}/{safe}": "/home/jovyan/work"} + + r_scripts_root = os.environ.get("DSP_R_SCRIPTS_ROOT", "/var/www/html/r_scripts") + if os.path.isdir(r_scripts_root): + volumes[r_scripts_root] = "/home/jovyan/work/r_scripts" + + return volumes + + +def _extract_person_id(username: str) -> Optional[str]: + match = re.search(r"(\d+)$", username or "") + return match.group(1) if match else None + + +def _run_sync(person_id: str) -> None: + command = [ + "docker", + "exec", + os.environ.get("DSP_APP_CONTAINER", "dsp_app"), + "php", + "/var/www/html/scripts/trigger_workspace_sync.php", + person_id, + ] + subprocess.run(command, check=False) + + +async def pre_spawn_hook(spawner): + username = spawner.user.name + spawner.volumes = _workspace_volume(username) + person_id = _extract_person_id(username) + if person_id: + _run_sync(person_id) + + +c.DockerSpawner.pre_spawn_hook = pre_spawn_hook + +_cull_token = os.environ.get("JUPYTERHUB_CULL_API_TOKEN") + +if _cull_token: + c.JupyterHub.services = [ + { + "name": "cull-idle", + "command": [ + "python", + "-m", + "jupyterhub_idle_culler", + "--timeout=3600", + "--cull-every=600", + "--concurrency=10", + ], + "api_token": _cull_token, + "admin": True, + } + ] +else: + logging.warning("JUPYTERHUB_CULL_API_TOKEN not set; idle culler disabled.") diff --git a/includes/auth.php b/includes/auth.php new file mode 100644 index 0000000..d6a3520 --- /dev/null +++ b/includes/auth.php @@ -0,0 +1,88 @@ + diff --git a/includes/footer_admin.php b/includes/footer_admin.php new file mode 100644 index 0000000..c680983 --- /dev/null +++ b/includes/footer_admin.php @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/includes/footer_contributor.php b/includes/footer_contributor.php new file mode 100644 index 0000000..c680983 --- /dev/null +++ b/includes/footer_contributor.php @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/includes/footer_owner.php b/includes/footer_owner.php new file mode 100644 index 0000000..c680983 --- /dev/null +++ b/includes/footer_owner.php @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/includes/footer_public.php b/includes/footer_public.php new file mode 100644 index 0000000..7239eaa --- /dev/null +++ b/includes/footer_public.php @@ -0,0 +1,67 @@ +
+
+
+
+
Introduction to NIPH
+

The National Institute of Public Health (NIPH) is dedicated to advancing public health through research, education, laboratories, and services. Our data sharing platform aims to provide accessible and reliable health data for informed decision-making.

+
+
+
Contact Us
+
    +
  • St.(289), Phnom Penh, Cambodia
  • +
  • +855 12 345 678
  • +
  • dac@niph.org.kh
  • +
+
    + + + +
+
+
+
Quick Links
+ +
+
+
+

© NIPH Data Sharing Platform. All rights reserved.

+
+ + +
+ + +
+
+ + + \ No newline at end of file diff --git a/includes/footer_user.php b/includes/footer_user.php new file mode 100644 index 0000000..c680983 --- /dev/null +++ b/includes/footer_user.php @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/includes/header_admin.php b/includes/header_admin.php new file mode 100644 index 0000000..9973719 --- /dev/null +++ b/includes/header_admin.php @@ -0,0 +1,163 @@ + + + + + NIPH Data Sharing Platform + + + + + + + + + + + \ No newline at end of file diff --git a/includes/header_contributor.php b/includes/header_contributor.php new file mode 100644 index 0000000..11a782d --- /dev/null +++ b/includes/header_contributor.php @@ -0,0 +1,150 @@ + + + + + NIPH Data Sharing Platform + + + + + + + + + + + \ No newline at end of file diff --git a/includes/header_owner.php b/includes/header_owner.php new file mode 100644 index 0000000..11a782d --- /dev/null +++ b/includes/header_owner.php @@ -0,0 +1,150 @@ + + + + + NIPH Data Sharing Platform + + + + + + + + + + + \ No newline at end of file diff --git a/includes/header_public.php b/includes/header_public.php new file mode 100644 index 0000000..cb0a6ff --- /dev/null +++ b/includes/header_public.php @@ -0,0 +1,107 @@ + + + + NIPH Data Sharing Platform + + + + + + + + \ No newline at end of file diff --git a/includes/header_user.php b/includes/header_user.php new file mode 100644 index 0000000..64356e7 --- /dev/null +++ b/includes/header_user.php @@ -0,0 +1,149 @@ + + + + + Data User Dashboard - NIPH DSP + + + + + + + + + + \ No newline at end of file diff --git a/includes/jupyter_config_reference.php b/includes/jupyter_config_reference.php new file mode 100644 index 0000000..70e0035 --- /dev/null +++ b/includes/jupyter_config_reference.php @@ -0,0 +1,63 @@ + +
+
+
+

Jupyter Service Reference

+

+ Configuration guidance (defaults, overrides, and security notes) now lives on the + Install & Configuration + page under R in JupyterHub Service. +

+

+ Use the snapshot below to confirm how this environment is currently resolving the notebook endpoint. +

+
+
Notebook Base URL
+
+ +
Published Port
+
+ +
Authentication Token
+
+ +
Workspace Mount
+
+
+

Active Environment Overrides

+ $value !== null && $value !== ''); + ?> + +
    + $value): ?> +
  • :
  • + +
+ +

No overrides detected; defaults from the Docker stack are in effect.

+ +
+
+
diff --git a/includes/jupyter_helpers.php b/includes/jupyter_helpers.php new file mode 100644 index 0000000..ab28322 --- /dev/null +++ b/includes/jupyter_helpers.php @@ -0,0 +1,203 @@ + + */ +function dsp_jupyter_defaults(): array { + return [ + 'base_url' => 'https://localhost', + 'token' => 'dsp-token', + 'workspace_root' => 'datasources', + 'port' => '443', + ]; +} + +/** + * Determines the external base URL for the Jupyter service. + * + * @return string + */ +function dsp_jupyter_base_url(): string { + $configured = getenv('JUPYTER_EXTERNAL_URL'); + if ($configured) { + return rtrim($configured, '/'); + } + + $defaults = dsp_jupyter_defaults(); + $scheme = (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') ? 'https' : 'http'; + $hostHeader = $_SERVER['HTTP_HOST'] ?? ''; + + if ($hostHeader) { + $hostname = explode(':', $hostHeader)[0]; + $port = dsp_jupyter_port(); + $authority = $hostname; + + $portIsDefault = ($scheme === 'http' && $port === '80') || ($scheme === 'https' && $port === '443'); + if (!$portIsDefault) { + $authority .= ':' . $port; + } + + return sprintf('%s://%s', $scheme, $authority); + } + + return rtrim($defaults['base_url'], '/'); +} + +/** + * Retrieves the token used to authenticate with Jupyter. + * + * @return string + */ +function dsp_jupyter_token(): string { + $authStrategy = strtolower((string) getenv('JUPYTERHUB_AUTH_STRATEGY')); + if ($authStrategy === 'oauth') { + return ''; + } + + $defaults = dsp_jupyter_defaults(); + + $envToken = getenv('JUPYTER_TOKEN'); + if ($envToken !== false) { + return $envToken; + } + + return $defaults['token']; +} + +/** + * Resolves the username JupyterHub should use for the active user. + * + * The template can be customised via JUPYTERHUB_USERNAME_TEMPLATE. + * Supported placeholders: + * {person_id} - numeric person identifier from the session + * {username} - DSP username from the session + * {email} - Session email if available + * + * @param int|null $personId Optional explicit person ID + * @return string|null Sanitised username or null when it cannot be determined. + */ +function dsp_resolve_jupyterhub_username(?int $personId, ?string $username = null, ?string $email = null): ?string { + if ($personId === null || $personId <= 0) { + return null; + } + + $template = getenv('JUPYTERHUB_USERNAME_TEMPLATE') ?: 'user_{person_id}'; + $usernameRaw = str_replace( + ['{person_id}', '{username}', '{email}'], + [ + (string) $personId, + (string) $username, + (string) $email, + ], + $template + ); + + $usernameSanitised = preg_replace('/[^a-zA-Z0-9._-]+/', '-', $usernameRaw); + $usernameSanitised = trim((string) $usernameSanitised, "-_."); + + return $usernameSanitised !== '' ? $usernameSanitised : null; +} + +function dsp_jupyterhub_username(?int $personId = null): ?string { + if ($personId === null) { + $personId = isset($_SESSION['person_id']) ? (int) $_SESSION['person_id'] : null; + } + + $username = $_SESSION['username'] ?? null; + $email = $_SESSION['email'] ?? null; + + return dsp_resolve_jupyterhub_username($personId, $username, $email); +} + +/** + * Builds the per-user route served by JupyterHub. + * + * The path template can be overridden with JUPYTERHUB_USER_PATH (e.g. "user/{username}/lab"). + * + * @param string $baseUrl Hub base URL. + * @param int|null $personId Optional explicit person ID. + * @return string Absolute path including the user segment, without trailing slash. + */ +function dsp_jupyterhub_user_route(string $baseUrl, ?int $personId = null): string { + $baseUrl = rtrim($baseUrl, '/'); + + $pathTemplate = getenv('JUPYTERHUB_USER_PATH'); + if ($pathTemplate === false || $pathTemplate === '') { + return $baseUrl; + } + + $personId = $personId ?? (isset($_SESSION['person_id']) ? (int) $_SESSION['person_id'] : null); + $username = dsp_jupyterhub_username($personId); + + if ($username === null) { + return $baseUrl; + } + + $relativePath = str_replace( + ['{username}', '{person_id}'], + [$username, (string) $personId], + ltrim($pathTemplate, '/') + ); + + return rtrim($baseUrl . '/' . $relativePath, '/'); +} + +/** + * Builds the iframe URL using the resolved base URL and token. + * + * @param string|null $baseUrl Optional override of the base URL. + * @param string|null $token Optional override of the token. + * @param int|null $personId Optional override of the person ID used for the route. + * + * @return string + */ +function dsp_jupyter_iframe_url(?string $baseUrl = null, ?string $token = null, ?int $personId = null): string { + $resolvedBase = rtrim($baseUrl ?: dsp_jupyter_base_url(), '/'); + $userRoute = dsp_jupyterhub_user_route($resolvedBase, $personId); + $finalToken = $token ?: dsp_jupyter_token(); + + return $finalToken + ? sprintf('%s?token=%s', $userRoute, urlencode($finalToken)) + : $userRoute; +} + +/** + * Determines the host port the Jupyter service is published on. + * + * @return string + */ +function dsp_jupyter_port(): string { + $configured = getenv('JUPYTER_PORT'); + if ($configured === false || $configured === '') { + $configured = getenv('JUPYTERHUB_PORT'); + } + if ($configured) { + return (string) $configured; + } + + $defaults = dsp_jupyter_defaults(); + + return $defaults['port']; +} + +/** + * Captures the environment overrides impacting the embedded Jupyter configuration. + * + * @return array + */ +function dsp_jupyter_env_overrides(): array { + return [ + 'JUPYTER_EXTERNAL_URL' => getenv('JUPYTER_EXTERNAL_URL') ?: null, + 'JUPYTER_TOKEN' => getenv('JUPYTER_TOKEN') ?: null, + 'JUPYTER_PORT' => getenv('JUPYTER_PORT') ?: null, + 'JUPYTERHUB_PORT' => getenv('JUPYTERHUB_PORT') ?: null, + 'DSP_APP_ORIGINS' => getenv('DSP_APP_ORIGINS') ?: null, + 'DSP_FRAME_ANCESTORS' => getenv('DSP_FRAME_ANCESTORS') ?: null, + 'JUPYTERHUB_USERNAME_TEMPLATE' => getenv('JUPYTERHUB_USERNAME_TEMPLATE') ?: null, + 'JUPYTERHUB_USER_PATH' => getenv('JUPYTERHUB_USER_PATH') ?: null, + ]; +} diff --git a/includes/nav_admin.php b/includes/nav_admin.php new file mode 100644 index 0000000..ea32c92 --- /dev/null +++ b/includes/nav_admin.php @@ -0,0 +1,119 @@ + + + +
+

DSP ADMIN

+ +
+ + +
+ + + diff --git a/includes/nav_contributor.php b/includes/nav_contributor.php new file mode 100644 index 0000000..1bb0c26 --- /dev/null +++ b/includes/nav_contributor.php @@ -0,0 +1,109 @@ + + + +
+

DSP DATA Contributor

+ +
+ + +
+ + + diff --git a/includes/nav_owner.php b/includes/nav_owner.php new file mode 100644 index 0000000..29ccb06 --- /dev/null +++ b/includes/nav_owner.php @@ -0,0 +1,108 @@ + + + +
+

DSP DATA OWNER

+ +
+ + +
+ + + diff --git a/includes/nav_public.php b/includes/nav_public.php new file mode 100644 index 0000000..cf55074 --- /dev/null +++ b/includes/nav_public.php @@ -0,0 +1,85 @@ + + + diff --git a/includes/nav_user.php b/includes/nav_user.php new file mode 100644 index 0000000..181a3e7 --- /dev/null +++ b/includes/nav_user.php @@ -0,0 +1,108 @@ + + + +
+

DSP DATA USER

+ +
+ + +
+ + + diff --git a/index.php b/index.php new file mode 100644 index 0000000..975612f --- /dev/null +++ b/index.php @@ -0,0 +1,562 @@ +getAllSlides(); // NEW: Get slides from database +// If no slides from DB, use placeholders +if (empty($slides)) { + $slides = [ + ['dspsslide_title_en' => 'Welcome to NIPH DSPS', 'dspsslide_description' => 'Your gateway to public health data and insights.', 'dspsslide_photoname' => 'https://placehold.co/1200x400/007bff/ffffff?text=Slide+1+-+Welcome'], + ['dspsslide_title_en' => 'Discover Research Data', 'dspsslide_description' => 'Access a wide range of datasets for your studies.', 'dspsslide_photoname' => 'https://placehold.co/1200x400/28a745/ffffff?text=Slide+2+-+Data+Insights'], + ['dspsslide_title_en' => 'Stay Informed', 'dspsslide_description' => 'Read the latest announcements and updates from NIPH.', 'dspsslide_photoname' => 'https://placehold.co/1200x400/ffc107/ffffff?text=Slide+3+-+Announcements'], + ]; +} else { + // Adjust photo path for display if coming from DB + foreach ($slides as &$slide) { + $slide['dspsslide_photoname'] = 'uploads/slides/' . $slide['dspsslide_photoname']; + } + unset($slide); // Unset reference +} + +$announcements = $announcement_manager->getAllAnnouncements('Published', 3); // Get 3 latest published announcements +// If no announcements from DB, use placeholders +if (empty($announcements)) { + $announcements = [ + ['dspsann_title' => 'Important Update on Data Policy', 'dspsann_description' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', 'dspsann_photopath' => 'https://placehold.co/400x250/6c757d/ffffff?text=Announcement+1'], + ['dspsann_title' => 'New Data Sources Available', 'dspsann_description' => 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.', 'dspsann_photopath' => 'https://placehold.co/400x250/6c757d/ffffff?text=Announcement+2'], + ['dspsann_title' => 'Upcoming Maintenance Schedule', 'dspsann_description' => 'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.', 'dspsann_photopath' => 'https://placehold.co/400x250/6c757d/ffffff?text=Announcement+3'], + ]; +} else { + // Adjust photo path for display if coming from DB + foreach ($announcements as &$ann) { + if (!empty($ann['dspsann_photopath'])) { + $ann['dspsann_photopath'] = 'uploads/announcements/' . $ann['dspsann_photopath']; + } else { + $ann['dspsann_photopath'] = 'https://placehold.co/400x250/6c757d/ffffff?text=No+Image'; + } + } + unset($ann); // Unset reference +} + + +// Fetch data types for classifications section +$data_types = $classification_manager->getAllDataTypes(); // Use Classifications manager +if (empty($data_types)) { + $data_types = [ + ['dspstds_name_en' => 'CSV Data', 'icon' => 'fas fa-file-csv', 'color' => 'text-primary', 'description' => 'Explore tabular data in CSV format.'], + ['dspstds_name_en' => 'JSON Data', 'icon' => 'fas fa-file-code', 'color' => 'text-success', 'description' => 'Access structured data in JSON format.'], + ['dspstds_name_en' => 'API Endpoints', 'icon' => 'fas fa-code-branch', 'color' => 'text-warning', 'description' => 'Integrate with data via API interfaces.'], + ['dspstds_name_en' => 'PDF Documents', 'icon' => 'fas fa-file-pdf', 'color' => 'text-danger', 'description' => 'View reports and documents in PDF.'], + ]; +} else { + // Map DB data to include placeholder icons/colors if not stored in DB + foreach ($data_types as &$type) { + switch ($type['dspstds_name_en']) { + case 'CSV': $type['icon'] = 'fas fa-file-csv'; $type['color'] = 'text-primary'; break; + case 'JSON': $type['icon'] = 'fas fa-file-code'; $type['color'] = 'text-success'; break; + case 'API': $type['icon'] = 'fas fa-code-branch'; $type['color'] = 'text-warning'; break; + case 'PDF': $type['icon'] = 'fas fa-file-pdf'; $type['color'] = 'text-danger'; break; + default: $type['icon'] = 'fas fa-file'; $type['color'] = 'text-secondary'; break; + } + $type['description'] = "Explore data of type " . htmlspecialchars($type['dspstds_name_en']) . "."; + } + unset($type); // Unset reference +} + +// Fetch all categories for the filter dropdown +$all_categories = $classification_manager->getAllCategories(); + +/** + * Sanitises rich text content by allowing a limited subset of HTML tags. + * + * @param string|null $value + * @return string + */ +function dsp_render_rich_text(?string $value): string { + if ($value === null) { + return ''; + } + + $allowed = '