first
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.github
|
||||
node_modules
|
||||
npm-debug.log
|
||||
storage/app/private
|
||||
storage/logs
|
||||
storage/framework/cache
|
||||
storage/framework/sessions
|
||||
storage/framework/views
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.md
|
||||
docker/transcription-worker
|
||||
tests
|
||||
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[{compose,docker-compose}.{yml,yaml}]
|
||||
indent_size = 4
|
||||
90
.env.example
Normal file
90
.env.example
Normal file
@@ -0,0 +1,90 @@
|
||||
APP_NAME="Speech2Text MBIP"
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://speech2text.mbip.my
|
||||
|
||||
APP_LOCALE=ms
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=ms_MY
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=error
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=speech2text
|
||||
DB_USERNAME=speech2text
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=true
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
CACHE_STORE=redis
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="noreply@mbip.my"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
# ============================================================
|
||||
# Storage
|
||||
# ============================================================
|
||||
PRIVATE_AUDIO_MAX_MB=200
|
||||
|
||||
# ============================================================
|
||||
# Transcription Engine
|
||||
# ============================================================
|
||||
TRANSCRIPTION_ENGINE=faster-whisper
|
||||
WHISPER_MODEL=small
|
||||
WHISPER_LANGUAGE=ms
|
||||
WHISPER_DEVICE=cpu
|
||||
WHISPER_COMPUTE_TYPE=int8
|
||||
TRANSCRIPTION_WORKER_URL=http://transcription-worker:8000
|
||||
|
||||
# ============================================================
|
||||
# Ollama (optional — local LLM post-processing)
|
||||
# Default: disabled. Jangan aktifkan jika tidak diperlukan.
|
||||
# ============================================================
|
||||
OLLAMA_ENABLED=false
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
OLLAMA_MODEL=llama3.1
|
||||
|
||||
# ============================================================
|
||||
# Admin Default (untuk seeder pertama kali)
|
||||
# Tukar password selepas login pertama!
|
||||
# ============================================================
|
||||
ADMIN_DEFAULT_NAME=
|
||||
ADMIN_DEFAULT_EMAIL=
|
||||
ADMIN_DEFAULT_PASSWORD=
|
||||
|
||||
# ============================================================
|
||||
# MySQL Root (untuk Docker)
|
||||
# ============================================================
|
||||
MYSQL_ROOT_PASSWORD=
|
||||
MYSQL_DATABASE=speech2text
|
||||
MYSQL_USER=speech2text
|
||||
MYSQL_PASSWORD=
|
||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.codex
|
||||
/.cursor/
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
_ide_helper.php
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
245
ARCHITECTURE.md
Normal file
245
ARCHITECTURE.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# ARCHITECTURE.md — Speech2Text MBIP
|
||||
|
||||
## Gambaran Keseluruhan
|
||||
|
||||
Sistem Speech-to-Text MBIP ialah aplikasi web dalaman yang membolehkan pengguna jabatan muat naik fail audio dan mendapatkan transkripsi teks Bahasa Melayu secara automatik. Semua pemprosesan berlaku dalam persekitaran Docker yang self-hosted, tanpa hantar data ke cloud luaran.
|
||||
|
||||
---
|
||||
|
||||
## Stack Teknologi
|
||||
|
||||
| Lapisan | Teknologi |
|
||||
|---|---|
|
||||
| Web Framework | Laravel 11 (PHP 8.3+) |
|
||||
| Database | MySQL 8 |
|
||||
| Cache / Queue | Redis |
|
||||
| Frontend | Bootstrap 5 + jQuery |
|
||||
| Web Server | Nginx + PHP-FPM |
|
||||
| Queue Worker | Laravel Queue (Redis driver) |
|
||||
| Transcription Engine | faster-whisper (Python) via HTTP API |
|
||||
| Post-processing (optional) | Ollama (local LLM) |
|
||||
| Container | Docker Compose |
|
||||
| Storage | Laravel Private Disk (local filesystem) |
|
||||
|
||||
---
|
||||
|
||||
## Seni Bina Perkhidmatan (Docker Compose)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ DOCKER NETWORK │
|
||||
│ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌──────────────────────┐ │
|
||||
│ │ nginx │───▶│ app │───▶│ mysql │ │
|
||||
│ │ :80/443 │ │ (php-fpm) │ │ :3306 │ │
|
||||
│ └───────────┘ └─────┬─────┘ └──────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────▼────┐ ┌──────────────────────┐ │
|
||||
│ │ redis │ │ transcription-worker│ │
|
||||
│ │ :6379 │ │ (Python FastAPI) │ │
|
||||
│ └─────────┘ │ :8000 │ │
|
||||
│ │ └──────────────────────┘ │
|
||||
│ ┌──────────┴───┐ ┌──────────────────────┐ │
|
||||
│ │queue-worker │ │ ollama (optional) │ │
|
||||
│ │(Laravel) │ │ :11434 │ │
|
||||
│ └──────────────┘ └──────────────────────┘ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ scheduler │ │
|
||||
│ │(Laravel cron)│ │
|
||||
│ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Perkhidmatan Docker
|
||||
|
||||
#### `app` — Laravel PHP-FPM
|
||||
- Image: php:8.3-fpm (custom Dockerfile)
|
||||
- Mount: `/svr/speech2text/app` → `/var/www/html`
|
||||
- Tanggungjawab: serve HTTP request, jalankan Artisan commands
|
||||
|
||||
#### `nginx`
|
||||
- Image: nginx:alpine
|
||||
- Port: 80, 443
|
||||
- Proxy pass ke `app:9000`
|
||||
|
||||
#### `mysql`
|
||||
- Image: mysql:8
|
||||
- Volume: data persistent
|
||||
- Database: `speech2text`
|
||||
|
||||
#### `redis`
|
||||
- Image: redis:alpine
|
||||
- Digunakan untuk: queue, cache, session
|
||||
|
||||
#### `queue-worker`
|
||||
- Build dari image `app` yang sama
|
||||
- Command: `php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600`
|
||||
- Proses job transcription, notification, audit
|
||||
|
||||
#### `scheduler`
|
||||
- Build dari image `app` yang sama
|
||||
- Command: `php artisan schedule:work`
|
||||
- Proses scheduled tasks (cleanup, retention policy)
|
||||
|
||||
#### `transcription-worker`
|
||||
- Image: Python 3.11 + faster-whisper (custom Dockerfile)
|
||||
- Port: 8000 (internal sahaja)
|
||||
- Expose endpoint `/transcribe` (POST)
|
||||
- Tidak accessible dari luar Docker network
|
||||
|
||||
#### `ollama` (optional)
|
||||
- Image: ollama/ollama
|
||||
- Hanya diaktifkan jika `OLLAMA_ENABLED=true`
|
||||
- Digunakan untuk post-processing teks selepas transcription
|
||||
|
||||
---
|
||||
|
||||
## Aliran Data Transcription
|
||||
|
||||
```
|
||||
User Upload Audio
|
||||
│
|
||||
▼
|
||||
[Laravel Controller]
|
||||
- Validate file (type, size)
|
||||
- Simpan ke storage/app/private/transcriptions/{uuid}/audio/
|
||||
- Cipta rekod TranscriptionProject (status: pending)
|
||||
- Dispatch TranscribeAudioJob ke Redis queue
|
||||
│
|
||||
▼
|
||||
[Queue Worker — TranscribeAudioJob]
|
||||
- Update status: processing
|
||||
- Baca fail audio dari private storage
|
||||
- Hantar ke transcription-worker via HTTP POST
|
||||
│
|
||||
▼
|
||||
[transcription-worker — Python FastAPI]
|
||||
- Terima audio (base64 atau multipart)
|
||||
- Jalankan faster-whisper dengan model yang dikonfig
|
||||
- Return transcript JSON
|
||||
│
|
||||
▼
|
||||
[Queue Worker — callback]
|
||||
- Simpan transcript ke database
|
||||
- Update status: completed / failed
|
||||
- Log audit
|
||||
- (Optional) Dispatch OllamaPostProcessJob
|
||||
│
|
||||
▼
|
||||
[User melihat hasil transcript]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Struktur Direktori Laravel
|
||||
|
||||
```
|
||||
app/
|
||||
├── Actions/ # Single-purpose action classes
|
||||
│ ├── CreateUserAction.php
|
||||
│ ├── DeactivateUserAction.php
|
||||
│ ├── TransferProjectOwnerAction.php
|
||||
│ └── ...
|
||||
├── Http/
|
||||
│ ├── Controllers/
|
||||
│ │ ├── Admin/
|
||||
│ │ │ ├── DashboardController.php
|
||||
│ │ │ ├── UserController.php
|
||||
│ │ │ ├── ProjectMetadataController.php
|
||||
│ │ │ ├── TransferOwnershipController.php
|
||||
│ │ │ └── AuditLogController.php
|
||||
│ │ └── User/
|
||||
│ │ ├── DashboardController.php
|
||||
│ │ ├── ProjectController.php
|
||||
│ │ ├── AudioController.php
|
||||
│ │ ├── TranscriptController.php
|
||||
│ │ ├── CollaboratorController.php
|
||||
│ │ ├── CommentController.php
|
||||
│ │ └── TranscriptVersionController.php
|
||||
│ ├── Requests/ # Form Request validation
|
||||
│ └── Middleware/
|
||||
│ ├── EnsureUserIsActive.php
|
||||
│ └── EnsureAdminCannotAccessContent.php
|
||||
├── Jobs/
|
||||
│ ├── TranscribeAudioJob.php
|
||||
│ └── OllamaPostProcessJob.php
|
||||
├── Models/
|
||||
│ ├── User.php
|
||||
│ ├── Department.php
|
||||
│ ├── TranscriptionProject.php
|
||||
│ ├── ProjectCollaborator.php
|
||||
│ ├── TranscriptVersion.php
|
||||
│ ├── ProjectComment.php
|
||||
│ └── AuditLog.php
|
||||
├── Policies/
|
||||
│ ├── TranscriptionProjectPolicy.php
|
||||
│ ├── CommentPolicy.php
|
||||
│ ├── TranscriptVersionPolicy.php
|
||||
│ └── UserPolicy.php
|
||||
├── Services/
|
||||
│ ├── TranscriptionService.php
|
||||
│ ├── OllamaService.php
|
||||
│ ├── AuditLogService.php
|
||||
│ └── StorageService.php
|
||||
└── ...
|
||||
|
||||
resources/
|
||||
├── views/
|
||||
│ ├── layouts/
|
||||
│ │ ├── app.blade.php # Layout pengguna
|
||||
│ │ └── admin.blade.php # Layout admin
|
||||
│ ├── auth/
|
||||
│ ├── admin/
|
||||
│ │ ├── dashboard.blade.php
|
||||
│ │ ├── users/
|
||||
│ │ ├── projects/
|
||||
│ │ └── audit-logs/
|
||||
│ └── user/
|
||||
│ ├── dashboard.blade.php
|
||||
│ ├── projects/
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authorization Model (Ringkas)
|
||||
|
||||
```
|
||||
Admin:
|
||||
✓ Urus pengguna (CRUD metadata)
|
||||
✓ Lihat statistik
|
||||
✓ Lihat metadata projek sahaja
|
||||
✓ Transfer ownership dengan justifikasi
|
||||
✓ Lihat audit log pentadbiran
|
||||
✗ Dengar / download audio
|
||||
✗ Baca transcript
|
||||
✗ Lihat komen
|
||||
✗ Masuk detail projek (kandungan)
|
||||
|
||||
Owner:
|
||||
✓ Semua akses ke projek sendiri
|
||||
✓ Urus collaborators
|
||||
✓ Delete projek / audio / teks
|
||||
✓ Retry transcription
|
||||
|
||||
Collaborator:
|
||||
✓ Lihat / edit transcript
|
||||
✓ Dengar audio
|
||||
✓ Buat komen
|
||||
✗ Delete projek
|
||||
✗ Transfer ownership
|
||||
✗ Urus collaborators lain
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prinsip Reka Bentuk
|
||||
|
||||
1. **Thin Controller** — logic dalam Action class atau Service, bukan dalam controller.
|
||||
2. **Policy Enforcement** — setiap action semak Policy, bukan hanya UI.
|
||||
3. **Private Storage** — tiada fail audio/teks dalam `public/` atau `storage/app/public/`.
|
||||
4. **UUID Routes** — gunakan UUID bukan ID integer dalam URL projek.
|
||||
5. **Audit Everything** — semua tindakan sensitif direkod dalam `audit_logs`.
|
||||
6. **Queue All Transcription** — proses transcription tidak boleh block HTTP request.
|
||||
7. **No Cloud Dependency** — semua AI/ML processing dalam Docker network sahaja.
|
||||
8. **Local-only Default** — Ollama disabled by default; hanya aktif jika eksplisit dikonfig.
|
||||
272
DATABASE_DESIGN.md
Normal file
272
DATABASE_DESIGN.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# DATABASE_DESIGN.md — Speech2Text MBIP
|
||||
|
||||
## Senarai Jadual
|
||||
|
||||
1. `users`
|
||||
2. `departments`
|
||||
3. `transcription_projects`
|
||||
4. `project_collaborators`
|
||||
5. `transcript_versions`
|
||||
6. `project_comments`
|
||||
7. `audit_logs`
|
||||
8. `sessions`
|
||||
9. `jobs` / `failed_jobs`
|
||||
10. `cache`
|
||||
|
||||
---
|
||||
|
||||
## Skema Jadual
|
||||
|
||||
### 1. `users`
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
role ENUM('admin', 'user') NOT NULL DEFAULT 'user',
|
||||
department_id BIGINT UNSIGNED NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
last_login_at TIMESTAMP NULL,
|
||||
email_verified_at TIMESTAMP NULL,
|
||||
remember_token VARCHAR(100) NULL,
|
||||
created_at TIMESTAMP NULL,
|
||||
updated_at TIMESTAMP NULL,
|
||||
deleted_at TIMESTAMP NULL, -- soft delete
|
||||
|
||||
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL
|
||||
);
|
||||
```
|
||||
|
||||
**Catatan:**
|
||||
- `deleted_at` hanya digunakan jika pengguna belum pernah guna aplikasi (tiada projek, tiada audit).
|
||||
- Jika pernah guna, hanya `is_active = 0` (deactivate), jangan hard delete.
|
||||
- `role` enum mudah diurus; boleh upgrade ke `spatie/laravel-permission` kemudian jika perlu.
|
||||
|
||||
---
|
||||
|
||||
### 2. `departments`
|
||||
|
||||
```sql
|
||||
CREATE TABLE departments (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
code VARCHAR(50) NULL UNIQUE,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NULL,
|
||||
updated_at TIMESTAMP NULL
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. `transcription_projects`
|
||||
|
||||
```sql
|
||||
CREATE TABLE transcription_projects (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
uuid CHAR(36) NOT NULL UNIQUE, -- digunakan dalam URL
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
owner_user_id BIGINT UNSIGNED NOT NULL,
|
||||
original_filename VARCHAR(500) NOT NULL,
|
||||
stored_audio_path VARCHAR(1000) NOT NULL, -- path relatif dalam private disk
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
file_size BIGINT UNSIGNED NOT NULL, -- bytes
|
||||
duration_seconds INT UNSIGNED NULL,
|
||||
language VARCHAR(10) NOT NULL DEFAULT 'ms',
|
||||
transcription_status ENUM('pending','processing','completed','failed') NOT NULL DEFAULT 'pending',
|
||||
transcription_engine VARCHAR(50) NULL, -- e.g. 'faster-whisper'
|
||||
transcript_text LONGTEXT NULL, -- kandungan sensitif
|
||||
transcript_confidence DECIMAL(5,4) NULL, -- 0.0000 - 1.0000
|
||||
error_message TEXT NULL,
|
||||
processed_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NULL,
|
||||
updated_at TIMESTAMP NULL,
|
||||
deleted_at TIMESTAMP NULL, -- soft delete
|
||||
|
||||
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE RESTRICT
|
||||
);
|
||||
```
|
||||
|
||||
**Catatan keselamatan:**
|
||||
- `stored_audio_path` adalah path dalam private storage, bukan URL awam.
|
||||
- `transcript_text` disimpan dalam database. Untuk keselamatan lanjut, boleh encrypt menggunakan Laravel `encrypted` cast.
|
||||
- Admin **tidak** boleh SELECT `transcript_text`, `stored_audio_path` melalui policy/query scope.
|
||||
|
||||
---
|
||||
|
||||
### 4. `project_collaborators`
|
||||
|
||||
```sql
|
||||
CREATE TABLE project_collaborators (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
role ENUM('editor', 'viewer') NOT NULL DEFAULT 'editor',
|
||||
added_by BIGINT UNSIGNED NOT NULL,
|
||||
created_at TIMESTAMP NULL,
|
||||
updated_at TIMESTAMP NULL,
|
||||
|
||||
UNIQUE KEY unique_project_user (project_id, user_id),
|
||||
FOREIGN KEY (project_id) REFERENCES transcription_projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE RESTRICT
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. `transcript_versions`
|
||||
|
||||
```sql
|
||||
CREATE TABLE transcript_versions (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id BIGINT UNSIGNED NOT NULL,
|
||||
edited_by BIGINT UNSIGNED NOT NULL,
|
||||
version_number INT UNSIGNED NOT NULL,
|
||||
old_text LONGTEXT NULL,
|
||||
new_text LONGTEXT NOT NULL,
|
||||
change_summary VARCHAR(500) NULL,
|
||||
created_at TIMESTAMP NULL,
|
||||
|
||||
FOREIGN KEY (project_id) REFERENCES transcription_projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (edited_by) REFERENCES users(id) ON DELETE RESTRICT
|
||||
);
|
||||
```
|
||||
|
||||
**Catatan:**
|
||||
- `old_text` dan `new_text` adalah snapshot penuh, bukan diff, untuk kemudahan restore.
|
||||
- Jangan masukkan `transcript_text` dalam `audit_logs`; gunakan jadual ini sebagai ganti.
|
||||
- Admin tidak boleh akses jadual ini.
|
||||
|
||||
---
|
||||
|
||||
### 6. `project_comments`
|
||||
|
||||
```sql
|
||||
CREATE TABLE project_comments (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
created_at TIMESTAMP NULL,
|
||||
updated_at TIMESTAMP NULL,
|
||||
deleted_at TIMESTAMP NULL, -- soft delete
|
||||
|
||||
FOREIGN KEY (project_id) REFERENCES transcription_projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. `audit_logs`
|
||||
|
||||
```sql
|
||||
CREATE TABLE audit_logs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
actor_user_id BIGINT UNSIGNED NULL, -- NULL jika sistem/background job
|
||||
actor_role VARCHAR(50) NULL,
|
||||
action VARCHAR(100) NOT NULL, -- e.g. 'user_deactivated'
|
||||
subject_type VARCHAR(100) NULL, -- e.g. 'App\Models\User'
|
||||
subject_id BIGINT UNSIGNED NULL,
|
||||
target_user_id BIGINT UNSIGNED NULL,
|
||||
project_id BIGINT UNSIGNED NULL,
|
||||
old_values JSON NULL, -- JANGAN masukkan transcript content
|
||||
new_values JSON NULL, -- JANGAN masukkan transcript content
|
||||
justification TEXT NULL,
|
||||
ip_address VARCHAR(45) NULL, -- support IPv6
|
||||
user_agent TEXT NULL,
|
||||
created_at TIMESTAMP NULL,
|
||||
|
||||
INDEX idx_actor (actor_user_id),
|
||||
INDEX idx_action (action),
|
||||
INDEX idx_project (project_id),
|
||||
INDEX idx_created (created_at)
|
||||
);
|
||||
```
|
||||
|
||||
**Tindakan yang diaudit:**
|
||||
|
||||
| action | Penerangan |
|
||||
|---|---|
|
||||
| `user_created` | Admin daftar pengguna baru |
|
||||
| `user_deactivated` | Admin deactivate pengguna |
|
||||
| `user_reactivated` | Admin aktifkan semula pengguna |
|
||||
| `user_deleted` | Admin delete pengguna (hanya jika belum guna) |
|
||||
| `user_email_changed` | Admin tukar emel pengguna |
|
||||
| `project_created` | Pengguna cipta projek |
|
||||
| `audio_uploaded` | Pengguna muat naik audio |
|
||||
| `transcription_started` | Queue worker mula proses |
|
||||
| `transcription_completed` | Queue worker selesai |
|
||||
| `transcription_failed` | Queue worker gagal |
|
||||
| `transcript_updated` | Owner/collaborator edit teks |
|
||||
| `transcript_version_restored` | Restore versi lama |
|
||||
| `collaborator_added` | Owner tambah collaborator |
|
||||
| `collaborator_removed` | Owner buang collaborator |
|
||||
| `comment_created` | Pengguna buat komen |
|
||||
| `project_deleted` | Owner delete projek |
|
||||
| `project_owner_transferred` | Admin transfer ownership |
|
||||
|
||||
---
|
||||
|
||||
## Hubungan Model (Eloquent Relationships)
|
||||
|
||||
```
|
||||
User
|
||||
├── hasMany: TranscriptionProject (as owner)
|
||||
├── belongsToMany: TranscriptionProject (through ProjectCollaborator)
|
||||
├── hasMany: TranscriptVersion (as editor)
|
||||
├── hasMany: ProjectComment
|
||||
├── belongsTo: Department
|
||||
└── hasMany: AuditLog (as actor)
|
||||
|
||||
TranscriptionProject
|
||||
├── belongsTo: User (owner)
|
||||
├── hasMany: ProjectCollaborator
|
||||
├── hasMany: TranscriptVersion
|
||||
├── hasMany: ProjectComment
|
||||
└── belongsToMany: User (collaborators)
|
||||
|
||||
Department
|
||||
└── hasMany: User
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Indeks Penting
|
||||
|
||||
```sql
|
||||
-- Cari projek mengikut status (untuk admin dashboard)
|
||||
ALTER TABLE transcription_projects ADD INDEX idx_status (transcription_status);
|
||||
|
||||
-- Cari projek mengikut owner
|
||||
ALTER TABLE transcription_projects ADD INDEX idx_owner (owner_user_id);
|
||||
|
||||
-- Cari versi mengikut projek (timeline)
|
||||
ALTER TABLE transcript_versions ADD INDEX idx_project_version (project_id, version_number);
|
||||
|
||||
-- Audit log search
|
||||
ALTER TABLE audit_logs ADD INDEX idx_target_user (target_user_id);
|
||||
ALTER TABLE audit_logs ADD INDEX idx_subject (subject_type, subject_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nota Keselamatan Data
|
||||
|
||||
1. **`transcript_text`** — Kolum sensitif. Boleh encrypt menggunakan Laravel cast `encrypted`:
|
||||
```php
|
||||
protected $casts = [
|
||||
'transcript_text' => 'encrypted',
|
||||
];
|
||||
```
|
||||
Ini encrypt menggunakan `APP_KEY`. Pastikan `APP_KEY` disimpan dengan selamat.
|
||||
|
||||
2. **`stored_audio_path`** — Simpan path relatif sahaja, bukan absolute path. Contoh: `transcriptions/abc-uuid/audio/recording.mp3`.
|
||||
|
||||
3. **Audit log** — Jangan masukkan `transcript_text` dalam `old_values` atau `new_values`. Gunakan `transcript_versions` untuk simpan snapshot teks.
|
||||
|
||||
4. **Soft delete** — `transcription_projects` dan `project_comments` menggunakan soft delete. Fail audio fizikal dikekalkan dalam private storage sehingga admin jalankan retention cleanup.
|
||||
318
DEPLOYMENT.md
Normal file
318
DEPLOYMENT.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# DEPLOYMENT.md — Speech2Text MBIP
|
||||
|
||||
## Keperluan Server
|
||||
|
||||
| Komponen | Minimum | Disyorkan |
|
||||
|---|---|---|
|
||||
| OS | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS |
|
||||
| CPU | 4 core | 8 core |
|
||||
| RAM | 8 GB | 16 GB |
|
||||
| Storage | 100 GB | 500 GB+ (untuk audio files) |
|
||||
| Docker | 24.x | Latest stable |
|
||||
| Docker Compose | 2.x | Latest stable |
|
||||
|
||||
**Nota:** RAM tambahan diperlukan jika menggunakan Whisper model yang lebih besar (medium/large).
|
||||
|
||||
---
|
||||
|
||||
## Struktur Direktori Server
|
||||
|
||||
```
|
||||
/svr/speech2text/
|
||||
├── .env # environment variables (JANGAN commit ke git)
|
||||
├── .env.example # template (commit ke git)
|
||||
├── docker-compose.yml # development / staging
|
||||
├── docker-compose.prod.yml # production override
|
||||
├── Dockerfile # Laravel app image
|
||||
├── docker/
|
||||
│ ├── nginx/
|
||||
│ │ └── default.conf
|
||||
│ ├── php/
|
||||
│ │ └── php.ini
|
||||
│ ├── supervisor/
|
||||
│ │ └── supervisord.conf
|
||||
│ └── transcription-worker/
|
||||
│ └── Dockerfile
|
||||
├── app/ # Laravel source code
|
||||
│ ├── storage/
|
||||
│ │ └── app/
|
||||
│ │ └── private/ # fail audio (tidak accessible web)
|
||||
│ └── ...
|
||||
└── mysql-data/ # MySQL data volume (auto-created)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Langkah Deployment Pertama Kali
|
||||
|
||||
### 1. Persediaan Server
|
||||
|
||||
```bash
|
||||
# Update server
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker
|
||||
|
||||
# Verify
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
### 2. Clone Repository
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /svr/speech2text
|
||||
sudo chown $USER:$USER /svr/speech2text
|
||||
cd /svr/speech2text
|
||||
git clone <repo-url> .
|
||||
```
|
||||
|
||||
### 3. Konfigurasi Environment
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env # atau gunakan editor pilihan anda
|
||||
```
|
||||
|
||||
Nilai wajib diisi dalam `.env`:
|
||||
|
||||
```
|
||||
APP_KEY= # akan dijanakan kemudian
|
||||
DB_PASSWORD=<password-kuat>
|
||||
ADMIN_DEFAULT_NAME=<nama-admin>
|
||||
ADMIN_DEFAULT_EMAIL=<email-admin>
|
||||
ADMIN_DEFAULT_PASSWORD=<password-admin-kuat>
|
||||
```
|
||||
|
||||
### 4. Build dan Jalankan Container
|
||||
|
||||
```bash
|
||||
cd /svr/speech2text
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### 5. Setup Laravel
|
||||
|
||||
```bash
|
||||
# Jana APP_KEY
|
||||
docker compose exec app php artisan key:generate
|
||||
|
||||
# Jalankan migration dan seeder
|
||||
docker compose exec app php artisan migrate --seed
|
||||
|
||||
# Set permission storage
|
||||
docker compose exec app chmod -R 775 storage bootstrap/cache
|
||||
docker compose exec app chown -R www-data:www-data storage bootstrap/cache
|
||||
```
|
||||
|
||||
### 6. Verify Services
|
||||
|
||||
```bash
|
||||
# Semak semua container berjalan
|
||||
docker compose ps
|
||||
|
||||
# Semak logs
|
||||
docker compose logs -f app
|
||||
docker compose logs -f queue-worker
|
||||
docker compose logs -f transcription-worker
|
||||
|
||||
# Semak queue worker
|
||||
docker compose exec app php artisan queue:monitor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment Update (Selepas Push Kod Baru)
|
||||
|
||||
```bash
|
||||
cd /svr/speech2text
|
||||
|
||||
# Pull kod terbaru
|
||||
git pull origin main
|
||||
|
||||
# Rebuild container jika ada perubahan Dockerfile
|
||||
docker compose up -d --build app
|
||||
|
||||
# Atau restart tanpa rebuild jika hanya kod PHP berubah
|
||||
docker compose restart app queue-worker scheduler
|
||||
|
||||
# Jalankan migration baru (jika ada)
|
||||
docker compose exec app php artisan migrate --force
|
||||
|
||||
# Clear cache
|
||||
docker compose exec app php artisan config:cache
|
||||
docker compose exec app php artisan route:cache
|
||||
docker compose exec app php artisan view:cache
|
||||
|
||||
# Restart queue worker untuk ambil kod baru
|
||||
docker compose restart queue-worker
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Override (`docker-compose.prod.yml`)
|
||||
|
||||
```bash
|
||||
# Jalankan dengan production config
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Konfigurasi Nginx (Luar Docker — jika ada reverse proxy)
|
||||
|
||||
Jika server ada Nginx luar Docker sebagai reverse proxy:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name speech2text.mbip.my;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/mbip.crt;
|
||||
ssl_certificate_key /etc/ssl/private/mbip.key;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080; # port Docker nginx
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
client_max_body_size 210M; # PRIVATE_AUDIO_MAX_MB + buffer
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name speech2text.mbip.my;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backup
|
||||
|
||||
### Database Backup
|
||||
|
||||
```bash
|
||||
# Backup manual
|
||||
docker compose exec mysql mysqldump \
|
||||
-u root -p$DB_PASSWORD \
|
||||
speech2text > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
# Compress
|
||||
gzip backup_*.sql
|
||||
```
|
||||
|
||||
### Storage Backup
|
||||
|
||||
```bash
|
||||
# Backup fail audio (private storage)
|
||||
tar -czf audio_backup_$(date +%Y%m%d).tar.gz \
|
||||
/svr/speech2text/app/storage/app/private/
|
||||
```
|
||||
|
||||
### Automated Backup (crontab)
|
||||
|
||||
```bash
|
||||
# Edit crontab
|
||||
crontab -e
|
||||
|
||||
# Tambah:
|
||||
0 2 * * * cd /svr/speech2text && docker compose exec -T mysql \
|
||||
mysqldump -u root -p$DB_PASSWORD speech2text | \
|
||||
gzip > /backup/db/speech2text_$(date +\%Y\%m\%d).sql.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
```bash
|
||||
# Semak status container
|
||||
docker compose ps
|
||||
|
||||
# Lihat penggunaan resource
|
||||
docker stats
|
||||
|
||||
# Lihat logs real-time
|
||||
docker compose logs -f
|
||||
|
||||
# Logs spesifik service
|
||||
docker compose logs -f queue-worker
|
||||
docker compose logs -f transcription-worker
|
||||
|
||||
# Semak queue jobs pending
|
||||
docker compose exec app php artisan queue:monitor
|
||||
|
||||
# Semak failed jobs
|
||||
docker compose exec app php artisan queue:failed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container tidak start
|
||||
|
||||
```bash
|
||||
docker compose logs <service-name>
|
||||
docker compose config # semak syntax docker-compose.yml
|
||||
```
|
||||
|
||||
### Transcription gagal
|
||||
|
||||
```bash
|
||||
# Semak transcription worker
|
||||
docker compose logs transcription-worker
|
||||
|
||||
# Test transcription worker secara manual
|
||||
docker compose exec app curl http://transcription-worker:8000/health
|
||||
|
||||
# Retry failed jobs
|
||||
docker compose exec app php artisan queue:retry all
|
||||
```
|
||||
|
||||
### Storage permission error
|
||||
|
||||
```bash
|
||||
docker compose exec app chmod -R 775 storage bootstrap/cache
|
||||
docker compose exec app chown -R www-data:www-data storage
|
||||
```
|
||||
|
||||
### Database migration error
|
||||
|
||||
```bash
|
||||
# Semak status migration
|
||||
docker compose exec app php artisan migrate:status
|
||||
|
||||
# Rollback jika perlu
|
||||
docker compose exec app php artisan migrate:rollback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist Pre-Production
|
||||
|
||||
- [ ] `APP_ENV=production`
|
||||
- [ ] `APP_DEBUG=false`
|
||||
- [ ] `APP_KEY` telah dijanakan dan disimpan dengan selamat
|
||||
- [ ] Database password kuat (minimum 16 karakter, alphanumeric + simbol)
|
||||
- [ ] HTTPS/TLS dikonfig
|
||||
- [ ] Firewall: hanya port 80/443 terbuka dari internet
|
||||
- [ ] Port 3306 (MySQL), 6379 (Redis), 8000 (transcription-worker) TIDAK terbuka dari internet
|
||||
- [ ] `.env` dalam `.gitignore`
|
||||
- [ ] `storage/app/private/` tidak accessible dari web
|
||||
- [ ] Backup berjalan secara automatik
|
||||
- [ ] Log rotation dikonfig
|
||||
- [ ] Admin default password ditukar selepas login pertama
|
||||
- [ ] Rate limiting aktif
|
||||
- [ ] Queue worker berjalan
|
||||
- [ ] Scheduler berjalan
|
||||
- [ ] Test upload audio berjaya
|
||||
- [ ] Test transcription berjaya
|
||||
- [ ] Test authorization (admin tidak dapat akses kandungan) lulus
|
||||
52
Dockerfile
Normal file
52
Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
||||
FROM php:8.4-fpm
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
libpng-dev \
|
||||
libonig-dev \
|
||||
libxml2-dev \
|
||||
libzip-dev \
|
||||
zip \
|
||||
unzip \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-install \
|
||||
pdo_mysql \
|
||||
mbstring \
|
||||
exif \
|
||||
pcntl \
|
||||
bcmath \
|
||||
gd \
|
||||
zip \
|
||||
opcache
|
||||
|
||||
# Install Redis extension
|
||||
RUN pecl install redis && docker-php-ext-enable redis
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Install PHP dependencies (production only)
|
||||
RUN composer install --optimize-autoloader --no-dev --no-interaction
|
||||
|
||||
# Set permissions
|
||||
RUN chown -R www-data:www-data /var/www/html \
|
||||
&& chmod -R 755 /var/www/html/storage \
|
||||
&& chmod -R 755 /var/www/html/bootstrap/cache
|
||||
|
||||
# Copy custom php.ini
|
||||
COPY docker/php/php.ini /usr/local/etc/php/conf.d/app.ini
|
||||
|
||||
EXPOSE 9000
|
||||
|
||||
CMD ["php-fpm"]
|
||||
58
README.md
Normal file
58
README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
||||
|
||||
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
|
||||
|
||||
## Agentic Development
|
||||
|
||||
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
|
||||
|
||||
```bash
|
||||
composer require laravel/boost --dev
|
||||
|
||||
php artisan boost:install
|
||||
```
|
||||
|
||||
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
277
SECURITY_MODEL.md
Normal file
277
SECURITY_MODEL.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# SECURITY_MODEL.md — Speech2Text MBIP
|
||||
|
||||
## Prinsip Asas Keselamatan
|
||||
|
||||
Sistem ini direka untuk menyimpan data sensitif kerajaan/PBT. Prinsip berikut mesti dipatuhi di setiap lapisan:
|
||||
|
||||
1. **Privacy by Design** — Admin ialah pentadbir akaun, bukan pentadbir kandungan.
|
||||
2. **Defense in Depth** — Authorization dikuatkuasakan di Policy, Controller, Query, dan UI.
|
||||
3. **Least Privilege** — Setiap role hanya mendapat akses minimum yang diperlukan.
|
||||
4. **Audit Everything** — Semua tindakan sensitif direkodkan dengan lengkap.
|
||||
5. **No Cloud Egress** — Data tidak keluar dari Docker network.
|
||||
|
||||
---
|
||||
|
||||
## Model Kebenaran (Authorization)
|
||||
|
||||
### Admin — APA yang BOLEH dan TIDAK BOLEH
|
||||
|
||||
| Tindakan | Admin |
|
||||
|---|---|
|
||||
| Daftar pengguna baru | ✓ |
|
||||
| Lihat senarai pengguna | ✓ |
|
||||
| Activate/deactivate pengguna | ✓ |
|
||||
| Delete pengguna (belum guna) | ✓ |
|
||||
| Tukar emel pengguna | ✓ |
|
||||
| Lihat statistik penggunaan (tanpa kandungan) | ✓ |
|
||||
| Lihat metadata projek (tajuk, owner, status, saiz, tarikh) | ✓ |
|
||||
| Transfer ownership projek (dengan justifikasi) | ✓ |
|
||||
| Lihat audit log pentadbiran | ✓ |
|
||||
| **Dengar / download audio** | ✗ |
|
||||
| **Baca transcript** | ✗ |
|
||||
| **Lihat komen dalam projek** | ✗ |
|
||||
| **Masuk halaman detail projek (kandungan)** | ✗ |
|
||||
| **Edit transcript** | ✗ |
|
||||
|
||||
### Owner (Pengguna Jabatan — pemilik projek)
|
||||
|
||||
| Tindakan | Owner |
|
||||
|---|---|
|
||||
| Cipta projek | ✓ |
|
||||
| Upload audio | ✓ |
|
||||
| Lihat / dengar audio sendiri | ✓ |
|
||||
| Lihat / edit transcript sendiri | ✓ |
|
||||
| Tambah / buang collaborator | ✓ |
|
||||
| Delete projek / audio / teks | ✓ |
|
||||
| Retry transcription | ✓ |
|
||||
| Lihat versi history | ✓ |
|
||||
| Restore versi lama | ✓ |
|
||||
| Buat komen | ✓ |
|
||||
|
||||
### Collaborator (Pengguna yang dikongsi projek)
|
||||
|
||||
| Tindakan | Collaborator |
|
||||
|---|---|
|
||||
| Lihat projek yang dikongsi | ✓ |
|
||||
| Dengar audio asal | ✓ |
|
||||
| Lihat / edit transcript | ✓ |
|
||||
| Buat komen | ✓ |
|
||||
| Lihat versi history | ✓ |
|
||||
| Restore versi lama | ✓ |
|
||||
| **Delete projek / audio / teks** | ✗ |
|
||||
| **Transfer ownership** | ✗ |
|
||||
| **Urus collaborator lain** | ✗ |
|
||||
|
||||
---
|
||||
|
||||
## Laravel Policies
|
||||
|
||||
### `TranscriptionProjectPolicy`
|
||||
|
||||
```php
|
||||
// Setiap method semak sama ada user adalah owner ATAU collaborator
|
||||
// Admin sengaja TIDAK diberikan akses ke content
|
||||
|
||||
view() → owner ATAU collaborator (bukan admin)
|
||||
create() → user aktif (bukan admin)
|
||||
update() → owner ATAU collaborator
|
||||
delete() → owner sahaja
|
||||
viewAudio() → owner ATAU collaborator
|
||||
downloadAudio()→ owner ATAU collaborator
|
||||
retryTranscription() → owner sahaja
|
||||
manageCollaborators() → owner sahaja
|
||||
transferOwner() → admin sahaja (metadata action, bukan content)
|
||||
viewMetadata() → admin ATAU owner (metadata minimum sahaja untuk admin)
|
||||
```
|
||||
|
||||
### `CommentPolicy`
|
||||
|
||||
```php
|
||||
view() → owner ATAU collaborator (bukan admin)
|
||||
create() → owner ATAU collaborator
|
||||
delete() → owner komen sendiri ATAU owner projek
|
||||
```
|
||||
|
||||
### `TranscriptVersionPolicy`
|
||||
|
||||
```php
|
||||
view() → owner ATAU collaborator
|
||||
restore() → owner ATAU collaborator
|
||||
```
|
||||
|
||||
### `UserPolicy`
|
||||
|
||||
```php
|
||||
create() → admin sahaja
|
||||
update() → admin sahaja
|
||||
delete() → admin sahaja (dengan semak tiada usage)
|
||||
activate() → admin sahaja
|
||||
deactivate() → admin sahaja
|
||||
changeEmail()→ admin sahaja
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lapisan Keselamatan
|
||||
|
||||
### L1: Authentication
|
||||
- Laravel Breeze/custom auth dengan session
|
||||
- `is_active` check di middleware `EnsureUserIsActive`
|
||||
- Rate limit login: 5 percubaan per minit per IP
|
||||
- Session expire: dikonfig dalam `.env`
|
||||
- CSRF protection aktif untuk semua form
|
||||
|
||||
### L2: Authorization (Policy)
|
||||
- Setiap controller action wajib panggil `$this->authorize()`
|
||||
- Jangan rely pada UI hide/show sahaja
|
||||
- Gunakan `abort(403)` jika policy gagal
|
||||
|
||||
### L3: Input Validation
|
||||
- Semua input melalui Form Request class
|
||||
- File upload: validate MIME type dari magic bytes (bukan extension sahaja)
|
||||
- Allowed audio types: `audio/mpeg`, `audio/wav`, `audio/mp4`, `audio/x-m4a`, `video/mp4`
|
||||
- Max file size: dikonfig `PRIVATE_AUDIO_MAX_MB` dalam `.env`
|
||||
- Rate limit upload: 10 upload per jam per user
|
||||
|
||||
### L4: Storage Security
|
||||
- Fail audio **tidak** disimpan dalam `public/` atau `storage/app/public/`
|
||||
- Path simpan: `storage/app/private/transcriptions/{project_uuid}/audio/{filename}`
|
||||
- Semua akses audio melalui controller yang semak policy
|
||||
- Guna `Storage::disk('private')->get()` untuk stream audio
|
||||
- Response header `Content-Disposition: inline` untuk audio player
|
||||
- Response header `X-Content-Type-Options: nosniff`
|
||||
|
||||
### L5: IDOR Prevention
|
||||
- Gunakan UUID dalam URL projek, bukan integer ID
|
||||
- Route: `/projects/{project:uuid}` bukan `/projects/{id}`
|
||||
- Policy semak ownership/collaborator, bukan hanya URL
|
||||
- Semua query pakai `where('owner_user_id', auth()->id())` atau policy scope
|
||||
|
||||
### L6: Output Sanitization
|
||||
- Escape semua output Blade dengan `{{ }}` (bukan `{!! !!}`)
|
||||
- Sanitize comment sebelum display dengan `htmlspecialchars()`
|
||||
- Transcript display menggunakan `{{ }}` escape
|
||||
- Content-Security-Policy header via middleware
|
||||
|
||||
### L7: Audit Trail
|
||||
- Semua tindakan sensitif log ke `audit_logs`
|
||||
- Log IP address dan user agent
|
||||
- Log nilai lama dan nilai baru (kecuali transcript content)
|
||||
- Audit log adalah append-only (tiada update/delete pada audit_logs)
|
||||
|
||||
### L8: Admin Content Isolation
|
||||
```php
|
||||
// Middleware atau scope yang enforce admin tidak dapat content
|
||||
class AdminProjectScope {
|
||||
public function apply($builder, $model) {
|
||||
if (auth()->user()->role === 'admin') {
|
||||
// Admin hanya dapat metadata sahaja
|
||||
$builder->select([
|
||||
'id', 'uuid', 'title', 'owner_user_id',
|
||||
'transcription_status', 'created_at',
|
||||
'file_size', 'duration_seconds', 'deleted_at'
|
||||
// TIADA transcript_text, stored_audio_path
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keselamatan Fail Audio
|
||||
|
||||
### Upload Flow
|
||||
```
|
||||
1. Validate MIME type (magic bytes, bukan extension)
|
||||
2. Generate nama fail baru yang random (jangan guna nama asal)
|
||||
3. Simpan ke path private: transcriptions/{uuid}/audio/{random_name}.{ext}
|
||||
4. Simpan nama asal dalam database (original_filename)
|
||||
5. Jangan simpan path dalam session atau cookie
|
||||
```
|
||||
|
||||
### Serve/Stream Audio
|
||||
```php
|
||||
// AudioController@stream
|
||||
public function stream(TranscriptionProject $project)
|
||||
{
|
||||
$this->authorize('viewAudio', $project);
|
||||
|
||||
$path = $project->stored_audio_path;
|
||||
|
||||
// Verify file exists
|
||||
abort_unless(Storage::disk('private')->exists($path), 404);
|
||||
|
||||
return response()->stream(function () use ($path) {
|
||||
$stream = Storage::disk('private')->readStream($path);
|
||||
fpassthru($stream);
|
||||
}, 200, [
|
||||
'Content-Type' => $project->mime_type,
|
||||
'Content-Length' => Storage::disk('private')->size($path),
|
||||
'Content-Disposition' => 'inline',
|
||||
'Cache-Control' => 'no-store, no-cache, private',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Konfigurasi Security Header
|
||||
|
||||
```php
|
||||
// middleware/SecurityHeaders.php
|
||||
'X-Frame-Options' => 'DENY',
|
||||
'X-XSS-Protection' => '1; mode=block',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Referrer-Policy' => 'strict-origin-when-cross-origin',
|
||||
'Content-Security-Policy' => "default-src 'self'; ..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Enkripsi Data Sensitif
|
||||
|
||||
### Transcript Text Encryption
|
||||
Gunakan Laravel `encrypted` cast untuk `transcript_text`:
|
||||
```php
|
||||
// Model TranscriptionProject
|
||||
protected $casts = [
|
||||
'transcript_text' => 'encrypted',
|
||||
];
|
||||
```
|
||||
- Data dienkripsi menggunakan `APP_KEY` sebelum simpan dalam database.
|
||||
- Decrypt berlaku secara automatik apabila diakses melalui Eloquent.
|
||||
- Tanpa `APP_KEY`, data tidak boleh dibaca walaupun database dicuri.
|
||||
- **Penting:** Simpan `APP_KEY` dalam `.env` dan jangan commit ke git.
|
||||
|
||||
---
|
||||
|
||||
## Semakan Keselamatan Deployment
|
||||
|
||||
Sebelum production, semak:
|
||||
|
||||
- [ ] `APP_DEBUG=false`
|
||||
- [ ] `APP_ENV=production`
|
||||
- [ ] `.env` tidak dalam git repository (ada dalam `.gitignore`)
|
||||
- [ ] `APP_KEY` telah dijanakan (`php artisan key:generate`)
|
||||
- [ ] Database password kuat dan berbeza dari default
|
||||
- [ ] `storage/app/private/` tidak accessible dari web
|
||||
- [ ] Nginx tidak serve `storage/` secara direct
|
||||
- [ ] HTTPS/TLS aktif
|
||||
- [ ] Rate limiting dikonfig
|
||||
- [ ] Fail upload di luar `public_html`
|
||||
- [ ] `storage:link` hanya untuk assets (bukan audio/transcript)
|
||||
- [ ] Queue worker berjalan dengan user yang restricted
|
||||
- [ ] Transcription worker tidak accessible dari internet (internal only)
|
||||
- [ ] Ollama tidak accessible dari internet (internal only)
|
||||
|
||||
---
|
||||
|
||||
## Nota untuk Pentadbir Sistem
|
||||
|
||||
1. **Backup `APP_KEY`** — Jika `APP_KEY` hilang, semua `transcript_text` yang dienkripsi tidak boleh dibaca.
|
||||
2. **Backup database secara berkala** — Termasuk `transcript_versions` yang menyimpan history.
|
||||
3. **Log rotation** — Pastikan `storage/logs/` dirotate supaya tidak penuh.
|
||||
4. **Retention policy** — Tentukan berapa lama fail audio disimpan selepas projek deleted.
|
||||
5. **Monitor disk space** — Audio files boleh membesar. Tetapkan alert.
|
||||
342
TASK_PLAN.md
Normal file
342
TASK_PLAN.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# TASK_PLAN.md — Speech2Text MBIP
|
||||
|
||||
## Ringkasan Fasa
|
||||
|
||||
| Fasa | Fokus | Anggaran |
|
||||
|---|---|---|
|
||||
| Fasa 1 | Auth, Role, User Management, Docker | Asas wajib |
|
||||
| Fasa 2 | Project CRUD, Upload Audio, Collaborator, Policies | Storage + RBAC |
|
||||
| Fasa 3 | Queue, Transcription Worker, Status, Retry | AI Integration |
|
||||
| Fasa 4 | Transcript Editor, Version History, Comments | Content Features |
|
||||
| Fasa 5 | Admin Dashboard, Audit Log, Transfer Ownership | Admin Tools |
|
||||
| Fasa 6 | Security Hardening, Tests, README, Production Checklist | Quality |
|
||||
|
||||
---
|
||||
|
||||
## FASA 1: Asas — Auth, Role, User Management, Docker
|
||||
|
||||
### Objektif
|
||||
Sistem boleh login, admin boleh urus pengguna, Docker berjalan.
|
||||
|
||||
### Checklist Fasa 1
|
||||
|
||||
#### 1.1 Struktur Projek Laravel
|
||||
- [ ] `laravel new speech2text-mbip` dengan PHP 8.3+
|
||||
- [ ] Konfigurasi `.env.example`
|
||||
- [ ] Setup `config/filesystems.php` — tambah private disk
|
||||
- [ ] Setup `config/auth.php`
|
||||
- [ ] Konfigurasi Laravel untuk UUID
|
||||
|
||||
#### 1.2 Docker Setup
|
||||
- [ ] `Dockerfile` untuk Laravel app (php:8.3-fpm)
|
||||
- [ ] `docker-compose.yml` dengan services: app, nginx, mysql, redis, queue-worker, scheduler
|
||||
- [ ] `docker/nginx/default.conf`
|
||||
- [ ] `docker/php/php.ini`
|
||||
- [ ] `.dockerignore`
|
||||
- [ ] Test: `docker compose up -d --build`
|
||||
|
||||
#### 1.3 Database — Migration Awal
|
||||
- [ ] Migration: `departments`
|
||||
- [ ] Migration: `users` (dengan `role`, `is_active`, `department_id`, `deleted_at`)
|
||||
- [ ] Migration: `audit_logs`
|
||||
- [ ] Migration: `sessions` (jika guna database session)
|
||||
|
||||
#### 1.4 Models
|
||||
- [ ] `User` model (dengan SoftDeletes, role check methods)
|
||||
- [ ] `Department` model
|
||||
- [ ] `AuditLog` model
|
||||
|
||||
#### 1.5 Authentication
|
||||
- [ ] Login form (Bootstrap 5)
|
||||
- [ ] Logout
|
||||
- [ ] Middleware `EnsureUserIsActive` — check `is_active` selepas login
|
||||
- [ ] Rate limit login (5 attempts/minute)
|
||||
- [ ] Redirect admin ke `/admin/dashboard`, user ke `/dashboard`
|
||||
|
||||
#### 1.6 Role Middleware
|
||||
- [ ] Middleware `EnsureAdmin` — untuk route `/admin/*`
|
||||
- [ ] Middleware `EnsureUser` — untuk route `/dashboard/*`, `/projects/*`
|
||||
- [ ] Register middleware dalam `bootstrap/app.php`
|
||||
|
||||
#### 1.7 Admin — User Management
|
||||
- [ ] `UserPolicy` — CRUD user oleh admin sahaja
|
||||
- [ ] `UserController` (Admin) dengan actions:
|
||||
- `index` — senarai semua pengguna
|
||||
- `create` / `store` — daftar pengguna baru
|
||||
- `edit` / `update` — tukar emel pengguna
|
||||
- `activate` — aktifkan pengguna
|
||||
- `deactivate` — deactivate pengguna
|
||||
- `destroy` — delete pengguna (hanya jika tiada usage)
|
||||
- [ ] Form Request: `CreateUserRequest`, `UpdateUserEmailRequest`
|
||||
- [ ] Views (Bootstrap 5): senarai pengguna, form cipta, form edit emel
|
||||
- [ ] Action classes: `CreateUserAction`, `DeactivateUserAction`, `ActivateUserAction`, `DeleteUserAction`, `ChangeUserEmailAction`
|
||||
|
||||
#### 1.8 Audit Log Service
|
||||
- [ ] `AuditLogService` dengan method `log()`
|
||||
- [ ] Panggil AuditLogService dalam setiap action yang relevan
|
||||
- [ ] Rekodkan: actor, action, target, old_values, new_values, ip, user_agent, justification
|
||||
|
||||
#### 1.9 Seeder
|
||||
- [ ] `DepartmentSeeder` — beberapa jabatan contoh
|
||||
- [ ] `AdminUserSeeder` — admin dari `.env` variables
|
||||
- [ ] `DatabaseSeeder` — panggil semua seeders
|
||||
|
||||
#### 1.10 Admin Dashboard (Asas)
|
||||
- [ ] Dashboard route `/admin/dashboard`
|
||||
- [ ] Layout admin (Bootstrap 5 sidebar)
|
||||
- [ ] Papar: jumlah pengguna aktif, jumlah pengguna deactive
|
||||
|
||||
### Arahan Test Fasa 1
|
||||
|
||||
```bash
|
||||
# Build dan jalankan
|
||||
cd /svr/speech2text
|
||||
docker compose up -d --build
|
||||
|
||||
# Setup
|
||||
docker compose exec app php artisan key:generate
|
||||
docker compose exec app php artisan migrate --seed
|
||||
|
||||
# Semak
|
||||
docker compose exec app php artisan migrate:status
|
||||
docker compose exec app php artisan route:list
|
||||
|
||||
# Test manual:
|
||||
# 1. Buka browser http://localhost
|
||||
# 2. Login sebagai admin (dari .env)
|
||||
# 3. Admin redirect ke /admin/dashboard
|
||||
# 4. Admin boleh lihat senarai pengguna
|
||||
# 5. Admin boleh daftar pengguna baru
|
||||
# 6. Admin boleh deactivate pengguna
|
||||
# 7. Pengguna deactivated tidak boleh login
|
||||
# 8. Admin boleh activate semula
|
||||
# 9. Admin boleh tukar emel
|
||||
# 10. Semak audit_logs ada rekod untuk setiap tindakan
|
||||
# 11. Login sebagai pengguna biasa -> redirect /dashboard
|
||||
# 12. Pengguna biasa tidak boleh akses /admin/*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FASA 2: Project CRUD, Upload Audio, Collaborator, Policies
|
||||
|
||||
### Checklist Fasa 2
|
||||
|
||||
#### 2.1 Migration
|
||||
- [ ] `transcription_projects`
|
||||
- [ ] `project_collaborators`
|
||||
|
||||
#### 2.2 Models & Relationships
|
||||
- [ ] `TranscriptionProject` (dengan UUID, SoftDeletes)
|
||||
- [ ] `ProjectCollaborator`
|
||||
- [ ] Relationships dalam `User` model
|
||||
|
||||
#### 2.3 Policies
|
||||
- [ ] `TranscriptionProjectPolicy` — view, create, update, delete, viewAudio, manageCollaborators
|
||||
- [ ] Register policies dalam `AuthServiceProvider`
|
||||
|
||||
#### 2.4 File Upload
|
||||
- [ ] Konfigurasi private disk dalam `config/filesystems.php`
|
||||
- [ ] Form Request: `UploadAudioRequest` (validate MIME, size)
|
||||
- [ ] `StorageService` — handle private file storage
|
||||
- [ ] AudioController — stream audio melalui policy check
|
||||
|
||||
#### 2.5 Project Controllers
|
||||
- [ ] `ProjectController` (User) — CRUD projek
|
||||
- [ ] `AudioController` (User) — stream/download audio
|
||||
- [ ] `CollaboratorController` (User) — tambah/buang collaborator
|
||||
|
||||
#### 2.6 Views
|
||||
- [ ] Project list (dashboard)
|
||||
- [ ] Create project + upload audio form
|
||||
- [ ] Project detail (placeholder transcript area)
|
||||
- [ ] Collaborator management panel
|
||||
|
||||
### Arahan Test Fasa 2
|
||||
|
||||
```bash
|
||||
# Test manual:
|
||||
# 1. Login sebagai user biasa
|
||||
# 2. Cipta projek baru dengan upload audio
|
||||
# 3. Semak fail ada dalam storage/app/private/ (bukan public/)
|
||||
# 4. Dengar audio dalam browser (via controller stream)
|
||||
# 5. Tambah collaborator
|
||||
# 6. Login sebagai collaborator — boleh lihat projek
|
||||
# 7. Login sebagai user lain (bukan collaborator) — 403
|
||||
# 8. Login sebagai admin — tidak boleh dengar audio (403)
|
||||
# 9. Admin hanya boleh lihat metadata projek
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FASA 3: Queue, Transcription Worker, Status, Retry
|
||||
|
||||
### Checklist Fasa 3
|
||||
|
||||
#### 3.1 Transcription Worker (Python)
|
||||
- [ ] `docker/transcription-worker/Dockerfile` (Python 3.11 + faster-whisper)
|
||||
- [ ] `docker/transcription-worker/main.py` (FastAPI endpoint `/transcribe`)
|
||||
- [ ] `/health` endpoint untuk monitoring
|
||||
- [ ] Tambah service ke `docker-compose.yml`
|
||||
|
||||
#### 3.2 Laravel Queue Job
|
||||
- [ ] `TranscribeAudioJob` — baca audio, hantar ke Python worker, simpan result
|
||||
- [ ] Handle timeout dan retry
|
||||
- [ ] Update `transcription_status` di database
|
||||
|
||||
#### 3.3 Ollama Integration (Optional)
|
||||
- [ ] `OllamaService` — panggil Ollama API untuk post-processing
|
||||
- [ ] `OllamaPostProcessJob` — dispatch selepas transcription selesai
|
||||
- [ ] Config via `.env`: `OLLAMA_ENABLED`, `OLLAMA_BASE_URL`, `OLLAMA_MODEL`
|
||||
|
||||
#### 3.4 UI Status
|
||||
- [ ] Status badge dalam project detail (pending/processing/completed/failed)
|
||||
- [ ] Auto-refresh status menggunakan polling atau SSE
|
||||
- [ ] Retry button untuk failed transcription (owner sahaja)
|
||||
|
||||
### Arahan Test Fasa 3
|
||||
|
||||
```bash
|
||||
# Semak transcription worker
|
||||
docker compose logs -f transcription-worker
|
||||
docker compose exec app curl http://transcription-worker:8000/health
|
||||
|
||||
# Upload audio dan monitor
|
||||
docker compose logs -f queue-worker
|
||||
docker compose exec app php artisan queue:work --once
|
||||
|
||||
# Semak result dalam database
|
||||
docker compose exec mysql mysql -u root -p speech2text \
|
||||
-e "SELECT id, title, transcription_status, transcript_text FROM transcription_projects LIMIT 5;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FASA 4: Transcript Editor, Version History, Comments
|
||||
|
||||
### Checklist Fasa 4
|
||||
|
||||
#### 4.1 Migration
|
||||
- [ ] `transcript_versions`
|
||||
- [ ] `project_comments`
|
||||
|
||||
#### 4.2 Models
|
||||
- [ ] `TranscriptVersion`
|
||||
- [ ] `ProjectComment` (dengan SoftDeletes)
|
||||
|
||||
#### 4.3 Policies
|
||||
- [ ] `TranscriptVersionPolicy`
|
||||
- [ ] `CommentPolicy`
|
||||
|
||||
#### 4.4 Controllers & Actions
|
||||
- [ ] `TranscriptController` — view dan update transcript
|
||||
- [ ] `TranscriptVersionController` — lihat history, restore
|
||||
- [ ] `CommentController` — CRUD komen
|
||||
|
||||
#### 4.5 Views
|
||||
- [ ] Transcript editor (textarea, save button)
|
||||
- [ ] Version history timeline
|
||||
- [ ] Restore version button
|
||||
- [ ] Comment/chat panel (sidebar atau bawah transcript)
|
||||
|
||||
### Arahan Test Fasa 4
|
||||
|
||||
```bash
|
||||
# Test:
|
||||
# 1. Edit transcript — semak transcript_versions ada rekod baru
|
||||
# 2. Edit kedua kali — semak version_number bertambah
|
||||
# 3. Restore version lama — semak transcript_text dikemaskini
|
||||
# 4. Collaborator boleh edit transcript
|
||||
# 5. Admin tidak boleh akses transcript editor (403)
|
||||
# 6. Buat komen
|
||||
# 7. Admin tidak boleh lihat komen (403)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FASA 5: Admin Dashboard, Audit Log, Transfer Ownership
|
||||
|
||||
### Checklist Fasa 5
|
||||
|
||||
#### 5.1 Admin Dashboard Lengkap
|
||||
- [ ] Statistik pengguna (aktif/deactivate)
|
||||
- [ ] Statistik projek (pending/processing/completed/failed)
|
||||
- [ ] Statistik storage
|
||||
- [ ] Top users by usage
|
||||
- [ ] Chart harian/bulanan (jQuery chart library)
|
||||
|
||||
#### 5.2 Admin Project Metadata List
|
||||
- [ ] Senarai projek dengan metadata sahaja (TANPA transcript_text)
|
||||
- [ ] Filter: status, tarikh, owner
|
||||
- [ ] Pagination
|
||||
|
||||
#### 5.3 Transfer Ownership
|
||||
- [ ] Form transfer ownership dengan justification field
|
||||
- [ ] `TransferProjectOwnerAction`
|
||||
- [ ] Option: jadikan owner lama sebagai collaborator
|
||||
- [ ] Audit log untuk transfer
|
||||
|
||||
#### 5.4 Audit Log Browser
|
||||
- [ ] Senarai audit log dengan filter
|
||||
- [ ] Filter: action, actor, tarikh, projek
|
||||
- [ ] Pagination
|
||||
- [ ] Export CSV (admin sahaja)
|
||||
|
||||
### Arahan Test Fasa 5
|
||||
|
||||
```bash
|
||||
# Test:
|
||||
# 1. Admin lihat dashboard dengan statistik
|
||||
# 2. Admin transfer ownership — semak audit log ada justifikasi
|
||||
# 3. Selepas transfer, owner baru boleh akses projek
|
||||
# 4. Owner lama (jika dijadikan collaborator) boleh akses
|
||||
# 5. Admin browse audit log — semak semua rekod ada
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FASA 6: Security Hardening, Tests, README, Production
|
||||
|
||||
### Checklist Fasa 6
|
||||
|
||||
#### 6.1 Feature Tests
|
||||
- [ ] `AdminCannotViewTranscriptTest`
|
||||
- [ ] `AdminCannotDownloadAudioTest`
|
||||
- [ ] `OwnerCanAccessOwnProjectTest`
|
||||
- [ ] `CollaboratorCanAccessSharedProjectTest`
|
||||
- [ ] `NonCollaboratorCannotAccessProjectTest`
|
||||
- [ ] `OnlyOwnerCanDeleteProjectTest`
|
||||
- [ ] `CollaboratorCanEditTranscriptTest`
|
||||
- [ ] `TranscriptEditCreatesVersionHistoryTest`
|
||||
- [ ] `AdminTransferOwnershipRequiresJustificationTest`
|
||||
- [ ] `DeactivateUserPreventsLoginTest`
|
||||
- [ ] `DeleteUnusedUserOnlyAllowedTest`
|
||||
- [ ] `EmailChangeCreatesAuditLogTest`
|
||||
|
||||
#### 6.2 Security Hardening
|
||||
- [ ] Security headers middleware
|
||||
- [ ] Rate limiting semua endpoint sensitif
|
||||
- [ ] Validate MIME type dari magic bytes
|
||||
- [ ] Semak semua `{{ }}` dalam Blade (bukan `{!! !!}`)
|
||||
- [ ] Semak CSRF token semua form
|
||||
- [ ] Remove debug routes
|
||||
|
||||
#### 6.3 Production Checklist
|
||||
- [ ] Semua item dalam `DEPLOYMENT.md` production checklist
|
||||
- [ ] `APP_DEBUG=false` di production
|
||||
- [ ] Config cache, route cache, view cache
|
||||
- [ ] Queue supervisor setup
|
||||
|
||||
#### 6.4 Documentation
|
||||
- [ ] README.md utama
|
||||
- [ ] Arahan deployment
|
||||
- [ ] Arahan development local
|
||||
|
||||
---
|
||||
|
||||
## Peringatan Penting
|
||||
|
||||
> **Prinsip Privasi:**
|
||||
> Admin = pentadbir AKAUN, bukan pentadbir KANDUNGAN.
|
||||
> Setiap fasa mesti pastikan admin tidak boleh akses audio, transcript, atau komen.
|
||||
> Semak dengan feature test sebelum anggap selesai.
|
||||
24
app/Actions/ActivateUserAction.php
Normal file
24
app/Actions/ActivateUserAction.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\AuditLogService;
|
||||
|
||||
class ActivateUserAction
|
||||
{
|
||||
public function __construct(private readonly AuditLogService $audit) {}
|
||||
|
||||
public function execute(User $user): void
|
||||
{
|
||||
$user->update(['is_active' => true]);
|
||||
|
||||
$this->audit->log('user_reactivated', [
|
||||
'subject_type' => User::class,
|
||||
'subject_id' => $user->id,
|
||||
'target_user_id' => $user->id,
|
||||
'old_values' => ['is_active' => false],
|
||||
'new_values' => ['is_active' => true],
|
||||
]);
|
||||
}
|
||||
}
|
||||
30
app/Actions/ChangeUserEmailAction.php
Normal file
30
app/Actions/ChangeUserEmailAction.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\AuditLogService;
|
||||
|
||||
class ChangeUserEmailAction
|
||||
{
|
||||
public function __construct(private readonly AuditLogService $audit) {}
|
||||
|
||||
public function execute(User $user, string $newEmail, ?string $justification = null): void
|
||||
{
|
||||
$oldEmail = $user->email;
|
||||
|
||||
$user->update([
|
||||
'email' => $newEmail,
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$this->audit->log('user_email_changed', [
|
||||
'subject_type' => User::class,
|
||||
'subject_id' => $user->id,
|
||||
'target_user_id' => $user->id,
|
||||
'old_values' => ['email' => $oldEmail],
|
||||
'new_values' => ['email' => $newEmail],
|
||||
'justification' => $justification,
|
||||
]);
|
||||
}
|
||||
}
|
||||
37
app/Actions/CreateUserAction.php
Normal file
37
app/Actions/CreateUserAction.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\AuditLogService;
|
||||
|
||||
class CreateUserAction
|
||||
{
|
||||
public function __construct(private readonly AuditLogService $audit) {}
|
||||
|
||||
public function execute(array $data): User
|
||||
{
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => $data['password'],
|
||||
'role' => $data['role'] ?? 'user',
|
||||
'department_id' => $data['department_id'] ?? null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->audit->log('user_created', [
|
||||
'subject_type' => User::class,
|
||||
'subject_id' => $user->id,
|
||||
'target_user_id' => $user->id,
|
||||
'new_values' => [
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'role' => $user->role,
|
||||
'department_id' => $user->department_id,
|
||||
],
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
28
app/Actions/DeactivateUserAction.php
Normal file
28
app/Actions/DeactivateUserAction.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\AuditLogService;
|
||||
|
||||
class DeactivateUserAction
|
||||
{
|
||||
public function __construct(private readonly AuditLogService $audit) {}
|
||||
|
||||
public function execute(User $user, ?string $justification = null): void
|
||||
{
|
||||
$user->update(['is_active' => false]);
|
||||
|
||||
// Invalidate all sessions for this user
|
||||
\DB::table('sessions')->where('user_id', $user->id)->delete();
|
||||
|
||||
$this->audit->log('user_deactivated', [
|
||||
'subject_type' => User::class,
|
||||
'subject_id' => $user->id,
|
||||
'target_user_id' => $user->id,
|
||||
'old_values' => ['is_active' => true],
|
||||
'new_values' => ['is_active' => false],
|
||||
'justification' => $justification,
|
||||
]);
|
||||
}
|
||||
}
|
||||
37
app/Actions/DeleteUserAction.php
Normal file
37
app/Actions/DeleteUserAction.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\AuditLogService;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class DeleteUserAction
|
||||
{
|
||||
public function __construct(private readonly AuditLogService $audit) {}
|
||||
|
||||
public function execute(User $user, ?string $justification = null): void
|
||||
{
|
||||
if ($user->hasUsage()) {
|
||||
throw ValidationException::withMessages([
|
||||
'user' => 'Pengguna ini tidak boleh dipadam kerana telah menggunakan sistem. Gunakan deactivate.',
|
||||
]);
|
||||
}
|
||||
|
||||
$userData = [
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'role' => $user->role,
|
||||
];
|
||||
|
||||
$this->audit->log('user_deleted', [
|
||||
'subject_type' => User::class,
|
||||
'subject_id' => $user->id,
|
||||
'target_user_id' => $user->id,
|
||||
'old_values' => $userData,
|
||||
'justification' => $justification,
|
||||
]);
|
||||
|
||||
$user->forceDelete();
|
||||
}
|
||||
}
|
||||
35
app/Actions/TransferOwnershipAction.php
Normal file
35
app/Actions/TransferOwnershipAction.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Models\User;
|
||||
use App\Services\AuditLogService;
|
||||
|
||||
class TransferOwnershipAction
|
||||
{
|
||||
public function __construct(private readonly AuditLogService $audit) {}
|
||||
|
||||
public function execute(TranscriptionProject $project, User $newOwner, string $justification): void
|
||||
{
|
||||
$oldOwner = $project->owner;
|
||||
|
||||
$project->update(['owner_user_id' => $newOwner->id]);
|
||||
|
||||
$this->audit->log('project_ownership_transferred', [
|
||||
'subject_type' => TranscriptionProject::class,
|
||||
'subject_id' => $project->id,
|
||||
'project_id' => $project->id,
|
||||
'target_user_id' => $newOwner->id,
|
||||
'old_values' => [
|
||||
'owner_user_id' => $oldOwner?->id,
|
||||
'owner_name' => $oldOwner?->name,
|
||||
],
|
||||
'new_values' => [
|
||||
'owner_user_id' => $newOwner->id,
|
||||
'owner_name' => $newOwner->name,
|
||||
],
|
||||
'justification' => $justification,
|
||||
]);
|
||||
}
|
||||
}
|
||||
89
app/Http/Controllers/Admin/AuditLogController.php
Normal file
89
app/Http/Controllers/Admin/AuditLogController.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AuditLogController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$logs = AuditLog::with(['actor', 'targetUser'])
|
||||
->when($request->action, fn ($q, $a) => $q->where('action', $a))
|
||||
->when($request->actor_id, fn ($q, $id) => $q->where('actor_user_id', $id))
|
||||
->when($request->target_id, fn ($q, $id) => $q->where('target_user_id', $id))
|
||||
->when($request->date_from, fn ($q, $d) => $q->whereDate('created_at', '>=', $d))
|
||||
->when($request->date_to, fn ($q, $d) => $q->whereDate('created_at', '<=', $d))
|
||||
->when($request->search, fn ($q, $s) =>
|
||||
$q->where('action', 'like', "%{$s}%")
|
||||
->orWhere('justification', 'like', "%{$s}%")
|
||||
->orWhere('ip_address', 'like', "%{$s}%")
|
||||
)
|
||||
->orderByDesc('created_at')
|
||||
->paginate(30)
|
||||
->withQueryString();
|
||||
|
||||
$actions = AuditLog::select('action')->distinct()->pluck('action')->sort()->values();
|
||||
$admins = User::where('role', 'admin')->orderBy('name')->get(['id', 'name']);
|
||||
|
||||
return view('admin.audit-logs.index', compact('logs', 'actions', 'admins'));
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$query = AuditLog::with(['actor', 'targetUser'])
|
||||
->when($request->action, fn ($q, $a) => $q->where('action', $a))
|
||||
->when($request->actor_id, fn ($q, $id) => $q->where('actor_user_id', $id))
|
||||
->when($request->target_id, fn ($q, $id) => $q->where('target_user_id', $id))
|
||||
->when($request->date_from, fn ($q, $d) => $q->whereDate('created_at', '>=', $d))
|
||||
->when($request->date_to, fn ($q, $d) => $q->whereDate('created_at', '<=', $d))
|
||||
->when($request->search, fn ($q, $s) =>
|
||||
$q->where('action', 'like', "%{$s}%")
|
||||
->orWhere('justification', 'like', "%{$s}%")
|
||||
->orWhere('ip_address', 'like', "%{$s}%")
|
||||
)
|
||||
->orderByDesc('created_at');
|
||||
|
||||
$filename = 'audit-log-' . now()->format('Ymd-His') . '.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($query) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
|
||||
// UTF-8 BOM untuk Excel
|
||||
fwrite($handle, "\xEF\xBB\xBF");
|
||||
|
||||
fputcsv($handle, [
|
||||
'Masa', 'Oleh', 'Peranan', 'Tindakan',
|
||||
'Sasaran', 'Projek ID', 'IP', 'Justifikasi',
|
||||
]);
|
||||
|
||||
$query->chunk(500, function ($logs) use ($handle) {
|
||||
foreach ($logs as $log) {
|
||||
fputcsv($handle, [
|
||||
$log->created_at?->format('d/m/Y H:i:s'),
|
||||
$log->actor?->name ?? 'Sistem',
|
||||
$log->actor_role ?? '',
|
||||
$log->action,
|
||||
$log->targetUser?->name ?? ($log->subject_type
|
||||
? class_basename($log->subject_type) . '#' . $log->subject_id
|
||||
: ''),
|
||||
$log->project_id ?? '',
|
||||
$log->ip_address ?? '',
|
||||
$log->justification ?? '',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
fclose($handle);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||
]);
|
||||
}
|
||||
}
|
||||
65
app/Http/Controllers/Admin/DashboardController.php
Normal file
65
app/Http/Controllers/Admin/DashboardController.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Department;
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$stats = [
|
||||
'users_active' => User::where('is_active', true)->where('role', 'user')->count(),
|
||||
'users_inactive' => User::where('is_active', false)->where('role', 'user')->count(),
|
||||
'users_total' => User::where('role', 'user')->count(),
|
||||
'projects_total' => TranscriptionProject::count(),
|
||||
'projects_pending' => TranscriptionProject::where('transcription_status', 'pending')->count(),
|
||||
'projects_processing' => TranscriptionProject::where('transcription_status', 'processing')->count(),
|
||||
'projects_completed' => TranscriptionProject::where('transcription_status', 'completed')->count(),
|
||||
'projects_failed' => TranscriptionProject::where('transcription_status', 'failed')->count(),
|
||||
'projects_deleted' => TranscriptionProject::onlyTrashed()->count(),
|
||||
'projects_last_7' => TranscriptionProject::where('created_at', '>=', now()->subDays(7))->count(),
|
||||
'projects_last_30' => TranscriptionProject::where('created_at', '>=', now()->subDays(30))->count(),
|
||||
'storage_bytes' => (int) TranscriptionProject::sum('file_size'),
|
||||
'storage_completed_bytes' => (int) TranscriptionProject::where('transcription_status', 'completed')->sum('file_size'),
|
||||
'duration_total_seconds' => (int) TranscriptionProject::whereNotNull('duration_seconds')->sum('duration_seconds'),
|
||||
];
|
||||
|
||||
// Top 5 pengguna paling aktif (by project count)
|
||||
$topUsers = User::where('role', 'user')
|
||||
->withCount('ownedProjects')
|
||||
->orderByDesc('owned_projects_count')
|
||||
->limit(5)
|
||||
->get(['id', 'name', 'email']);
|
||||
|
||||
// Trend projek 30 hari lepas (group by date)
|
||||
$trendRaw = TranscriptionProject::select(
|
||||
DB::raw('DATE(created_at) as date'),
|
||||
DB::raw('COUNT(*) as total'),
|
||||
DB::raw('SUM(CASE WHEN transcription_status = "completed" THEN 1 ELSE 0 END) as completed'),
|
||||
DB::raw('SUM(CASE WHEN transcription_status = "failed" THEN 1 ELSE 0 END) as failed')
|
||||
)
|
||||
->where('created_at', '>=', now()->subDays(29)->startOfDay())
|
||||
->groupBy(DB::raw('DATE(created_at)'))
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Projek mengikut jabatan (metadata sahaja)
|
||||
$deptStats = Department::select('departments.name')
|
||||
->selectRaw('COUNT(tp.id) as project_count')
|
||||
->leftJoin('users', 'users.department_id', '=', 'departments.id')
|
||||
->leftJoin('transcription_projects as tp', 'tp.owner_user_id', '=', 'users.id')
|
||||
->where('departments.is_active', true)
|
||||
->groupBy('departments.id', 'departments.name')
|
||||
->orderByDesc('project_count')
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
return view('admin.dashboard', compact('stats', 'topUsers', 'trendRaw', 'deptStats'));
|
||||
}
|
||||
}
|
||||
65
app/Http/Controllers/Admin/ProjectMetadataController.php
Normal file
65
app/Http/Controllers/Admin/ProjectMetadataController.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Actions\TransferOwnershipAction;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\TransferOwnershipRequest;
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProjectMetadataController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// Admin hanya mendapat metadata — tiada transcript_text, tiada stored_audio_path
|
||||
$projects = TranscriptionProject::withTrashed()
|
||||
->with(['owner:id,name,email', 'projectCollaborators'])
|
||||
->select([
|
||||
'id', 'uuid', 'title', 'owner_user_id',
|
||||
'transcription_status', 'transcription_engine',
|
||||
'file_size', 'duration_seconds',
|
||||
'language',
|
||||
'created_at', 'deleted_at',
|
||||
// Sengaja TIDAK ambil: transcript_text, stored_audio_path
|
||||
])
|
||||
->when($request->status, fn ($q, $s) => $q->where('transcription_status', $s))
|
||||
->when($request->search, fn ($q, $s) =>
|
||||
$q->where('title', 'like', "%{$s}%")
|
||||
)
|
||||
->when($request->owner_id, fn ($q, $id) => $q->where('owner_user_id', $id))
|
||||
->orderByDesc('created_at')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
$owners = User::where('role', 'user')
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
|
||||
return view('admin.projects.index', compact('projects', 'owners'));
|
||||
}
|
||||
|
||||
public function transferOwner(
|
||||
TransferOwnershipRequest $request,
|
||||
TranscriptionProject $project,
|
||||
TransferOwnershipAction $action
|
||||
): RedirectResponse {
|
||||
$this->authorize('transferOwner', $project);
|
||||
|
||||
$newOwner = User::findOrFail($request->new_owner_id);
|
||||
|
||||
// Pastikan pemilik baru adalah pengguna aktif (bukan admin)
|
||||
abort_if($newOwner->isAdmin(), 403, 'Admin tidak boleh menjadi pemilik projek.');
|
||||
abort_if(! $newOwner->isActive(), 422, 'Pengguna tidak aktif tidak boleh menerima pemindahan projek.');
|
||||
abort_if($project->owner_user_id === $newOwner->id, 422, 'Projek sudah dimiliki oleh pengguna ini.');
|
||||
|
||||
$action->execute($project, $newOwner, $request->justification);
|
||||
|
||||
return redirect()->route('admin.projects.index')
|
||||
->with('success', "Pemilikan projek \"{$project->title}\" berjaya dipindahkan kepada {$newOwner->name}.");
|
||||
}
|
||||
}
|
||||
106
app/Http/Controllers/Admin/UserController.php
Normal file
106
app/Http/Controllers/Admin/UserController.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Actions\ActivateUserAction;
|
||||
use App\Actions\ChangeUserEmailAction;
|
||||
use App\Actions\CreateUserAction;
|
||||
use App\Actions\DeactivateUserAction;
|
||||
use App\Actions\DeleteUserAction;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\CreateUserRequest;
|
||||
use App\Http\Requests\Admin\UpdateUserEmailRequest;
|
||||
use App\Models\Department;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorize('viewAny', User::class);
|
||||
|
||||
$users = User::with('department')
|
||||
->where('role', 'user')
|
||||
->when($request->search, fn ($q, $s) =>
|
||||
$q->where('name', 'like', "%{$s}%")
|
||||
->orWhere('email', 'like', "%{$s}%")
|
||||
)
|
||||
->when($request->status, fn ($q, $s) =>
|
||||
$q->where('is_active', $s === 'active')
|
||||
)
|
||||
->orderBy('name')
|
||||
->paginate(20)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.users.index', compact('users'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$this->authorize('create', User::class);
|
||||
|
||||
$departments = Department::where('is_active', true)->orderBy('name')->get();
|
||||
|
||||
return view('admin.users.create', compact('departments'));
|
||||
}
|
||||
|
||||
public function store(CreateUserRequest $request, CreateUserAction $action): RedirectResponse
|
||||
{
|
||||
$action->execute($request->validated());
|
||||
|
||||
return redirect()->route('admin.users.index')
|
||||
->with('success', 'Pengguna berjaya didaftarkan.');
|
||||
}
|
||||
|
||||
public function edit(User $user): View
|
||||
{
|
||||
$this->authorize('changeEmail', $user);
|
||||
|
||||
$departments = Department::where('is_active', true)->orderBy('name')->get();
|
||||
|
||||
return view('admin.users.edit', compact('user', 'departments'));
|
||||
}
|
||||
|
||||
public function updateEmail(UpdateUserEmailRequest $request, User $user, ChangeUserEmailAction $action): RedirectResponse
|
||||
{
|
||||
$this->authorize('changeEmail', $user);
|
||||
|
||||
$action->execute($user, $request->email, $request->justification);
|
||||
|
||||
return redirect()->route('admin.users.index')
|
||||
->with('success', "E-mel pengguna {$user->name} berjaya dikemaskini.");
|
||||
}
|
||||
|
||||
public function activate(Request $request, User $user, ActivateUserAction $action): RedirectResponse
|
||||
{
|
||||
$this->authorize('activate', $user);
|
||||
|
||||
$action->execute($user);
|
||||
|
||||
return redirect()->route('admin.users.index')
|
||||
->with('success', "Akaun {$user->name} berjaya diaktifkan.");
|
||||
}
|
||||
|
||||
public function deactivate(Request $request, User $user, DeactivateUserAction $action): RedirectResponse
|
||||
{
|
||||
$this->authorize('deactivate', $user);
|
||||
|
||||
$action->execute($user, $request->justification);
|
||||
|
||||
return redirect()->route('admin.users.index')
|
||||
->with('success', "Akaun {$user->name} berjaya dinyahaktifkan.");
|
||||
}
|
||||
|
||||
public function destroy(Request $request, User $user, DeleteUserAction $action): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $user);
|
||||
|
||||
$action->execute($user, $request->justification);
|
||||
|
||||
return redirect()->route('admin.users.index')
|
||||
->with('success', "Pengguna {$user->name} berjaya dipadam.");
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
58
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$request->authenticate();
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
// Semak is_active selepas login berjaya
|
||||
if (! $user->is_active) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return back()->withErrors([
|
||||
'email' => 'Akaun anda telah dinyahaktifkan. Sila hubungi pentadbir.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Update last login
|
||||
$user->update(['last_login_at' => now()]);
|
||||
|
||||
// Redirect mengikut role
|
||||
if ($user->isAdmin()) {
|
||||
return redirect()->intended(route('admin.dashboard'));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('user.dashboard'));
|
||||
}
|
||||
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
}
|
||||
13
app/Http/Controllers/Controller.php
Normal file
13
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Bus\DispatchesJobs;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
abstract class Controller extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
|
||||
}
|
||||
40
app/Http/Controllers/User/AudioController.php
Normal file
40
app/Http/Controllers/User/AudioController.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Services\StorageService;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AudioController extends Controller
|
||||
{
|
||||
public function __construct(private readonly StorageService $storage) {}
|
||||
|
||||
public function stream(TranscriptionProject $project): StreamedResponse
|
||||
{
|
||||
$this->authorize('viewAudio', $project);
|
||||
|
||||
$path = $project->stored_audio_path;
|
||||
|
||||
abort_unless($this->storage->exists($path), 404);
|
||||
|
||||
$size = $this->storage->size($path);
|
||||
$mimeType = $project->mime_type;
|
||||
|
||||
return response()->stream(function () use ($path) {
|
||||
$stream = $this->storage->readStream($path);
|
||||
if ($stream) {
|
||||
fpassthru($stream);
|
||||
fclose($stream);
|
||||
}
|
||||
}, 200, [
|
||||
'Content-Type' => $mimeType,
|
||||
'Content-Length' => $size,
|
||||
'Content-Disposition' => 'inline',
|
||||
'Cache-Control' => 'no-store, no-cache, private, must-revalidate',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Accept-Ranges' => 'bytes',
|
||||
]);
|
||||
}
|
||||
}
|
||||
72
app/Http/Controllers/User/CollaboratorController.php
Normal file
72
app/Http/Controllers/User/CollaboratorController.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ProjectCollaborator;
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Models\User;
|
||||
use App\Services\AuditLogService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CollaboratorController extends Controller
|
||||
{
|
||||
public function store(Request $request, TranscriptionProject $project, AuditLogService $audit): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageCollaborators', $project);
|
||||
|
||||
$data = $request->validate([
|
||||
'email' => ['required', 'email', 'exists:users,email'],
|
||||
'role' => ['nullable', 'in:editor,viewer'],
|
||||
]);
|
||||
|
||||
$user = User::where('email', $data['email'])->where('is_active', true)->firstOrFail();
|
||||
|
||||
// Owner tidak boleh jadi collaborator
|
||||
if ($project->isOwnedBy($user)) {
|
||||
return back()->withErrors(['email' => 'Pengguna ini adalah pemilik projek.']);
|
||||
}
|
||||
|
||||
// Elak duplicate
|
||||
$existing = ProjectCollaborator::where('project_id', $project->id)
|
||||
->where('user_id', $user->id)
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
return back()->withErrors(['email' => 'Pengguna ini sudah menjadi collaborator.']);
|
||||
}
|
||||
|
||||
ProjectCollaborator::create([
|
||||
'project_id' => $project->id,
|
||||
'user_id' => $user->id,
|
||||
'role' => $data['role'] ?? 'editor',
|
||||
'added_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$audit->log('collaborator_added', [
|
||||
'project_id' => $project->id,
|
||||
'target_user_id' => $user->id,
|
||||
'new_values' => ['email' => $user->email, 'role' => $data['role'] ?? 'editor'],
|
||||
]);
|
||||
|
||||
return back()->with('success', "{$user->name} berjaya ditambah sebagai collaborator.");
|
||||
}
|
||||
|
||||
public function destroy(TranscriptionProject $project, User $user, AuditLogService $audit): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageCollaborators', $project);
|
||||
|
||||
ProjectCollaborator::where('project_id', $project->id)
|
||||
->where('user_id', $user->id)
|
||||
->delete();
|
||||
|
||||
$audit->log('collaborator_removed', [
|
||||
'project_id' => $project->id,
|
||||
'target_user_id' => $user->id,
|
||||
'old_values' => ['email' => $user->email],
|
||||
]);
|
||||
|
||||
return back()->with('success', "{$user->name} telah dibuang daripada collaborator.");
|
||||
}
|
||||
}
|
||||
52
app/Http/Controllers/User/CommentController.php
Normal file
52
app/Http/Controllers/User/CommentController.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\User\StoreCommentRequest;
|
||||
use App\Models\ProjectComment;
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Services\AuditLogService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function store(
|
||||
StoreCommentRequest $request,
|
||||
TranscriptionProject $project,
|
||||
AuditLogService $audit
|
||||
): RedirectResponse {
|
||||
$this->authorize('create', [ProjectComment::class, $project]);
|
||||
|
||||
$comment = $project->comments()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'message' => $request->message,
|
||||
]);
|
||||
|
||||
$audit->log('comment_added', [
|
||||
'project_id' => $project->id,
|
||||
'new_values' => ['comment_id' => $comment->id],
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Komen berjaya ditambah.');
|
||||
}
|
||||
|
||||
public function destroy(
|
||||
TranscriptionProject $project,
|
||||
ProjectComment $comment,
|
||||
AuditLogService $audit
|
||||
): RedirectResponse {
|
||||
abort_if($comment->project_id !== $project->id, 404);
|
||||
|
||||
$this->authorize('delete', $comment);
|
||||
|
||||
$audit->log('comment_deleted', [
|
||||
'project_id' => $project->id,
|
||||
'old_values' => ['comment_id' => $comment->id],
|
||||
]);
|
||||
|
||||
$comment->delete();
|
||||
|
||||
return back()->with('success', 'Komen berjaya dipadam.');
|
||||
}
|
||||
}
|
||||
26
app/Http/Controllers/User/DashboardController.php
Normal file
26
app/Http/Controllers/User/DashboardController.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TranscriptionProject;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$ownedProjects = TranscriptionProject::where('owner_user_id', $user->id)
|
||||
->orderByDesc('created_at')
|
||||
->paginate(10, ['*'], 'owned');
|
||||
|
||||
$sharedProjects = $user->collaboratingProjects()
|
||||
->orderByDesc('transcription_projects.created_at')
|
||||
->paginate(10, ['*'], 'shared');
|
||||
|
||||
return view('user.dashboard', compact('ownedProjects', 'sharedProjects'));
|
||||
}
|
||||
}
|
||||
122
app/Http/Controllers/User/ProjectController.php
Normal file
122
app/Http/Controllers/User/ProjectController.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\User\CreateProjectRequest;
|
||||
use App\Jobs\TranscribeAudioJob;
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Services\AuditLogService;
|
||||
use App\Services\StorageService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
public function create(): View
|
||||
{
|
||||
$this->authorize('create', TranscriptionProject::class);
|
||||
|
||||
return view('user.projects.create');
|
||||
}
|
||||
|
||||
public function store(CreateProjectRequest $request, StorageService $storage, AuditLogService $audit): RedirectResponse
|
||||
{
|
||||
$this->authorize('create', TranscriptionProject::class);
|
||||
|
||||
$file = $request->file('audio');
|
||||
$uuid = (string) \Illuminate\Support\Str::uuid();
|
||||
$path = $storage->storeAudio($file, $uuid);
|
||||
|
||||
$project = TranscriptionProject::create([
|
||||
'uuid' => $uuid,
|
||||
'title' => $request->title,
|
||||
'description' => $request->description,
|
||||
'owner_user_id' => Auth::id(),
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'stored_audio_path' => $path,
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'file_size' => $file->getSize(),
|
||||
'language' => $request->language ?? 'ms',
|
||||
'transcription_status' => 'pending',
|
||||
'transcription_engine' => config('speech2text.transcription.engine'),
|
||||
]);
|
||||
|
||||
$audit->log('project_created', [
|
||||
'project_id' => $project->id,
|
||||
'new_values' => ['title' => $project->title, 'uuid' => $project->uuid],
|
||||
]);
|
||||
|
||||
$audit->log('audio_uploaded', [
|
||||
'project_id' => $project->id,
|
||||
'new_values' => [
|
||||
'original_filename' => $project->original_filename,
|
||||
'file_size' => $project->file_size,
|
||||
'mime_type' => $project->mime_type,
|
||||
],
|
||||
]);
|
||||
|
||||
TranscribeAudioJob::dispatch($project);
|
||||
|
||||
return redirect()->route('user.projects.show', $project)
|
||||
->with('success', 'Projek berjaya dicipta. Audio sedang dalam barisan untuk ditranskripkan.');
|
||||
}
|
||||
|
||||
public function show(TranscriptionProject $project): View
|
||||
{
|
||||
$this->authorize('view', $project);
|
||||
|
||||
$project->load(['owner', 'collaborators.department', 'transcriptVersions.editor', 'comments.user']);
|
||||
|
||||
return view('user.projects.show', compact('project'));
|
||||
}
|
||||
|
||||
// Endpoint polling status — JSON, tidak mendedahkan transcript
|
||||
public function status(TranscriptionProject $project): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $project);
|
||||
|
||||
return response()->json([
|
||||
'status' => $project->transcription_status,
|
||||
'duration_seconds' => $project->duration_seconds,
|
||||
'processed_at' => $project->processed_at?->format('d/m/Y H:i'),
|
||||
'error_message' => $project->isFailed() ? $project->error_message : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function retry(TranscriptionProject $project, AuditLogService $audit): RedirectResponse
|
||||
{
|
||||
$this->authorize('retryTranscription', $project);
|
||||
|
||||
$project->update([
|
||||
'transcription_status' => 'pending',
|
||||
'error_message' => null,
|
||||
]);
|
||||
|
||||
$audit->log('transcription_started', [
|
||||
'project_id' => $project->id,
|
||||
'new_values' => ['retry' => true],
|
||||
]);
|
||||
|
||||
TranscribeAudioJob::dispatch($project);
|
||||
|
||||
return back()->with('success', 'Transkripsi sedang dicuba semula.');
|
||||
}
|
||||
|
||||
public function destroy(TranscriptionProject $project, AuditLogService $audit): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $project);
|
||||
|
||||
$audit->log('project_deleted', [
|
||||
'project_id' => $project->id,
|
||||
'old_values' => ['title' => $project->title, 'uuid' => $project->uuid],
|
||||
]);
|
||||
|
||||
$project->delete();
|
||||
|
||||
return redirect()->route('user.dashboard')
|
||||
->with('success', 'Projek berjaya dipadam.');
|
||||
}
|
||||
}
|
||||
112
app/Http/Controllers/User/TranscriptController.php
Normal file
112
app/Http/Controllers/User/TranscriptController.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\User\UpdateTranscriptRequest;
|
||||
use App\Http\Requests\User\UploadExternalTranscriptRequest;
|
||||
use App\Jobs\OllamaPostProcessJob;
|
||||
use App\Models\TranscriptVersion;
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Services\AuditLogService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class TranscriptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Simpan perubahan teks transkripsi dan cipta versi baru.
|
||||
*/
|
||||
public function update(
|
||||
UpdateTranscriptRequest $request,
|
||||
TranscriptionProject $project,
|
||||
AuditLogService $audit
|
||||
): RedirectResponse {
|
||||
$this->authorize('editTranscript', $project);
|
||||
|
||||
$oldText = $project->transcript_text;
|
||||
|
||||
$nextVersion = ($project->transcriptVersions()->max('version_number') ?? 0) + 1;
|
||||
|
||||
TranscriptVersion::create([
|
||||
'project_id' => $project->id,
|
||||
'edited_by' => Auth::id(),
|
||||
'version_number' => $nextVersion,
|
||||
'old_text' => $oldText,
|
||||
'new_text' => $request->transcript_text,
|
||||
'change_summary' => $request->change_summary ?? 'Dikemaskini oleh pengguna',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$project->update([
|
||||
'transcript_text' => $request->transcript_text,
|
||||
'transcription_status' => 'completed',
|
||||
]);
|
||||
|
||||
$audit->log('transcript_edited', [
|
||||
'project_id' => $project->id,
|
||||
'new_values' => ['version_number' => $nextVersion],
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Transkripsi berjaya disimpan.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Muat naik fail transkripsi .txt dari luar sistem.
|
||||
*/
|
||||
public function uploadExternal(
|
||||
UploadExternalTranscriptRequest $request,
|
||||
TranscriptionProject $project,
|
||||
AuditLogService $audit
|
||||
): RedirectResponse {
|
||||
$this->authorize('editTranscript', $project);
|
||||
|
||||
$text = file_get_contents($request->file('transcript_file')->getRealPath());
|
||||
|
||||
// Bersihkan BOM dan normalkan line endings
|
||||
$text = preg_replace('/^\xef\xbb\xbf/', '', $text);
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
$text = trim($text);
|
||||
|
||||
if ($text === '') {
|
||||
return back()->withErrors(['transcript_file' => 'Fail transkripsi kosong.']);
|
||||
}
|
||||
|
||||
$oldText = $project->transcript_text;
|
||||
$nextVersion = ($project->transcriptVersions()->max('version_number') ?? 0) + 1;
|
||||
|
||||
TranscriptVersion::create([
|
||||
'project_id' => $project->id,
|
||||
'edited_by' => Auth::id(),
|
||||
'version_number' => $nextVersion,
|
||||
'old_text' => $oldText,
|
||||
'new_text' => $text,
|
||||
'change_summary' => 'Transkripsi dimuat naik secara manual',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$project->update([
|
||||
'transcript_text' => $text,
|
||||
'transcription_status' => 'completed',
|
||||
'transcription_engine' => 'manual',
|
||||
'processed_at' => $project->processed_at ?? now(),
|
||||
]);
|
||||
|
||||
$audit->log('transcript_uploaded_external', [
|
||||
'project_id' => $project->id,
|
||||
'new_values' => ['version_number' => $nextVersion],
|
||||
]);
|
||||
|
||||
// Hantar ke Ollama jika pengguna minta dan Ollama dikonfigurasi
|
||||
if ($request->boolean('clean_with_ollama') && config('speech2text.ollama.enabled')) {
|
||||
OllamaPostProcessJob::dispatch($project);
|
||||
}
|
||||
|
||||
$msg = 'Transkripsi berjaya dimuat naik.';
|
||||
if ($request->boolean('clean_with_ollama') && config('speech2text.ollama.enabled')) {
|
||||
$msg .= ' Pembersihan Ollama sedang diproses.';
|
||||
}
|
||||
|
||||
return back()->with('success', $msg);
|
||||
}
|
||||
}
|
||||
67
app/Http/Controllers/User/TranscriptVersionController.php
Normal file
67
app/Http/Controllers/User/TranscriptVersionController.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TranscriptVersion;
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Services\AuditLogService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class TranscriptVersionController extends Controller
|
||||
{
|
||||
public function index(TranscriptionProject $project): View
|
||||
{
|
||||
$this->authorize('viewVersionHistory', $project);
|
||||
|
||||
$versions = $project->transcriptVersions()
|
||||
->with('editor')
|
||||
->orderByDesc('version_number')
|
||||
->paginate(20);
|
||||
|
||||
return view('user.projects.versions', compact('project', 'versions'));
|
||||
}
|
||||
|
||||
public function restore(
|
||||
TranscriptionProject $project,
|
||||
TranscriptVersion $version,
|
||||
AuditLogService $audit
|
||||
): RedirectResponse {
|
||||
$this->authorize('restoreVersion', $project);
|
||||
|
||||
// Pastikan versi ini milik projek ini
|
||||
abort_if($version->project_id !== $project->id, 404);
|
||||
|
||||
$oldText = $project->transcript_text;
|
||||
$nextVersion = ($project->transcriptVersions()->max('version_number') ?? 0) + 1;
|
||||
|
||||
TranscriptVersion::create([
|
||||
'project_id' => $project->id,
|
||||
'edited_by' => Auth::id(),
|
||||
'version_number' => $nextVersion,
|
||||
'old_text' => $oldText,
|
||||
'new_text' => $version->new_text,
|
||||
'change_summary' => "Dipulihkan dari versi {$version->version_number}",
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$project->update([
|
||||
'transcript_text' => $version->new_text,
|
||||
'transcription_status' => 'completed',
|
||||
]);
|
||||
|
||||
$audit->log('transcript_version_restored', [
|
||||
'project_id' => $project->id,
|
||||
'new_values' => [
|
||||
'restored_from_version' => $version->version_number,
|
||||
'new_version' => $nextVersion,
|
||||
],
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('user.projects.show', $project)
|
||||
->with('success', "Versi {$version->version_number} berjaya dipulihkan.");
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/EnsureAdmin.php
Normal file
19
app/Http/Middleware/EnsureAdmin.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user() || ! $request->user()->isAdmin()) {
|
||||
abort(403, 'Akses tidak dibenarkan.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/EnsureRegularUser.php
Normal file
19
app/Http/Middleware/EnsureRegularUser.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureRegularUser
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user() || ! $request->user()->isUser()) {
|
||||
abort(403, 'Akses tidak dibenarkan.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
25
app/Http/Middleware/EnsureUserIsActive.php
Normal file
25
app/Http/Middleware/EnsureUserIsActive.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureUserIsActive
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (Auth::check() && ! Auth::user()->is_active) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')
|
||||
->withErrors(['email' => 'Akaun anda telah dinyahaktifkan. Sila hubungi pentadbir.']);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
41
app/Http/Middleware/SecurityHeaders.php
Normal file
41
app/Http/Middleware/SecurityHeaders.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SecurityHeaders
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('X-XSS-Protection', '1; mode=block');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
|
||||
$response->headers->set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; " .
|
||||
"script-src 'self' 'unsafe-inline'; " .
|
||||
"style-src 'self' 'unsafe-inline'; " .
|
||||
"img-src 'self' data: blob:; " .
|
||||
"font-src 'self'; " .
|
||||
"connect-src 'self'; " .
|
||||
"media-src 'self' blob:; " .
|
||||
"object-src 'none'; " .
|
||||
"base-uri 'self'; " .
|
||||
"form-action 'self'; " .
|
||||
"frame-ancestors 'self';"
|
||||
);
|
||||
|
||||
if ($request->isSecure()) {
|
||||
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/Admin/CreateUserRequest.php
Normal file
36
app/Http/Requests/Admin/CreateUserRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class CreateUserRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->isAdmin();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'confirmed', Password::min(8)->mixedCase()->numbers()],
|
||||
'role' => ['required', 'in:admin,user'],
|
||||
'department_id' => ['nullable', 'exists:departments,id'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Nama',
|
||||
'email' => 'E-mel',
|
||||
'password' => 'Kata laluan',
|
||||
'role' => 'Peranan',
|
||||
'department_id' => 'Jabatan',
|
||||
];
|
||||
}
|
||||
}
|
||||
29
app/Http/Requests/Admin/TransferOwnershipRequest.php
Normal file
29
app/Http/Requests/Admin/TransferOwnershipRequest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TransferOwnershipRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->isAdmin();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'new_owner_id' => ['required', 'integer', 'exists:users,id'],
|
||||
'justification' => ['required', 'string', 'min:10', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'new_owner_id' => 'Pemilik baru',
|
||||
'justification' => 'Justifikasi',
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/Admin/UpdateUserEmailRequest.php
Normal file
32
app/Http/Requests/Admin/UpdateUserEmailRequest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateUserEmailRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->isAdmin();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$userId = $this->route('user')->id;
|
||||
|
||||
return [
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($userId)],
|
||||
'justification'=> ['nullable', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'email' => 'E-mel baru',
|
||||
'justification' => 'Justifikasi',
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/Http/Requests/Auth/LoginRequest.php
Normal file
64
app/Http/Requests/Auth/LoginRequest.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
public function authenticate(): void
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
public function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event(new Lockout($this));
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->string('email')) . '|' . $this->ip());
|
||||
}
|
||||
}
|
||||
52
app/Http/Requests/User/CreateProjectRequest.php
Normal file
52
app/Http/Requests/User/CreateProjectRequest.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\User;
|
||||
|
||||
use App\Rules\ValidAudioMagicBytes;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CreateProjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->isActive();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$maxKb = config('speech2text.upload.max_mb', 200) * 1024;
|
||||
$extensions = implode(',', config('speech2text.upload.allowed_extensions'));
|
||||
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'audio' => [
|
||||
'required',
|
||||
'file',
|
||||
"mimes:{$extensions}",
|
||||
"max:{$maxKb}",
|
||||
new ValidAudioMagicBytes(),
|
||||
],
|
||||
'language' => ['nullable', 'string', 'in:ms,en'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'Tajuk projek',
|
||||
'description' => 'Penerangan',
|
||||
'audio' => 'Fail audio',
|
||||
'language' => 'Bahasa',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
$maxMb = config('speech2text.upload.max_mb', 200);
|
||||
return [
|
||||
'audio.max' => "Saiz fail audio melebihi had maksimum {$maxMb}MB.",
|
||||
'audio.mimes' => 'Format fail tidak disokong. Guna: mp3, wav, m4a, mp4, aac, ogg, flac, webm.',
|
||||
];
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/User/StoreCommentRequest.php
Normal file
28
app/Http/Requests/User/StoreCommentRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\User;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreCommentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'message' => ['required', 'string', 'max:2000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'message.required' => 'Mesej komen tidak boleh kosong.',
|
||||
'message.max' => 'Mesej terlalu panjang (maks 2,000 aksara).',
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/User/UpdateTranscriptRequest.php
Normal file
30
app/Http/Requests/User/UpdateTranscriptRequest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\User;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateTranscriptRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true; // Policy diperiksa dalam controller
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'transcript_text' => ['required', 'string', 'max:500000'],
|
||||
'change_summary' => ['nullable', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'transcript_text.required' => 'Teks transkripsi tidak boleh kosong.',
|
||||
'transcript_text.max' => 'Teks transkripsi terlalu panjang (maks 500,000 aksara).',
|
||||
'change_summary.max' => 'Ringkasan perubahan terlalu panjang (maks 500 aksara).',
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/User/UploadExternalTranscriptRequest.php
Normal file
32
app/Http/Requests/User/UploadExternalTranscriptRequest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\User;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UploadExternalTranscriptRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'transcript_file' => ['required', 'file', 'mimetypes:text/plain', 'extensions:txt', 'max:10240'],
|
||||
'clean_with_ollama' => ['nullable', 'boolean'],
|
||||
'confirmed' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'transcript_file.required' => 'Sila pilih fail transkripsi (.txt).',
|
||||
'transcript_file.mimetypes' => 'Fail mesti berformat teks biasa (.txt).',
|
||||
'transcript_file.extensions'=> 'Hanya fail .txt dibenarkan.',
|
||||
'transcript_file.max' => 'Saiz fail terlalu besar (maks 10 MB).',
|
||||
];
|
||||
}
|
||||
}
|
||||
76
app/Jobs/OllamaPostProcessJob.php
Normal file
76
app/Jobs/OllamaPostProcessJob.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\TranscriptVersion;
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Services\OllamaService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class OllamaPostProcessJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 180;
|
||||
public int $tries = 2;
|
||||
public int $backoff = 15;
|
||||
|
||||
public function __construct(private readonly TranscriptionProject $project) {}
|
||||
|
||||
public function handle(OllamaService $ollama): void
|
||||
{
|
||||
$project = $this->project;
|
||||
|
||||
// Reload fresh — status may have changed between dispatch and execution
|
||||
$project->refresh();
|
||||
|
||||
if (! $project->isCompleted() || ! $project->transcript_text) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $ollama->isAvailable()) {
|
||||
Log::info("OllamaPostProcessJob: Ollama tidak tersedia, skip projek #{$project->id}");
|
||||
return;
|
||||
}
|
||||
|
||||
$cleaned = $ollama->cleanTranscript($project->transcript_text, $project->language);
|
||||
|
||||
if ($cleaned === null) {
|
||||
Log::warning("OllamaPostProcessJob: hasil null untuk projek #{$project->id}");
|
||||
return;
|
||||
}
|
||||
|
||||
$oldText = $project->transcript_text;
|
||||
$nextVersion = ($project->transcriptVersions()->max('version_number') ?? 0) + 1;
|
||||
|
||||
TranscriptVersion::create([
|
||||
'project_id' => $project->id,
|
||||
'edited_by' => $project->owner_user_id,
|
||||
'version_number' => $nextVersion,
|
||||
'old_text' => $oldText,
|
||||
'new_text' => $cleaned,
|
||||
'change_summary' => 'Dibersihkan oleh Ollama',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$project->update(['transcript_text' => $cleaned]);
|
||||
|
||||
AuditLog::create([
|
||||
'actor_user_id' => null,
|
||||
'actor_role' => 'system',
|
||||
'action' => 'transcript_postprocessed',
|
||||
'project_id' => $project->id,
|
||||
'new_values' => [
|
||||
'project_uuid' => $project->uuid,
|
||||
'model' => config('speech2text.ollama.model'),
|
||||
],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
120
app/Jobs/TranscribeAudioJob.php
Normal file
120
app/Jobs/TranscribeAudioJob.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Services\StorageService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TranscribeAudioJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 600;
|
||||
public int $tries = 3;
|
||||
public int $backoff = 30;
|
||||
|
||||
public function __construct(private readonly TranscriptionProject $project) {}
|
||||
|
||||
public function handle(StorageService $storage): void
|
||||
{
|
||||
$project = $this->project;
|
||||
|
||||
$project->update(['transcription_status' => 'processing']);
|
||||
$this->auditLog('transcription_started');
|
||||
|
||||
$tmpPath = null;
|
||||
|
||||
try {
|
||||
$path = $project->stored_audio_path;
|
||||
|
||||
abort_unless($storage->exists($path), 500, 'Fail audio tidak dijumpai.');
|
||||
|
||||
// Stream storage → temp file to avoid loading large files into memory
|
||||
$tmpPath = tempnam(sys_get_temp_dir(), 'whisper_');
|
||||
$src = $storage->readStream($path);
|
||||
$dst = fopen($tmpPath, 'wb');
|
||||
|
||||
stream_copy_to_stream($src, $dst);
|
||||
|
||||
fclose($src);
|
||||
fclose($dst);
|
||||
|
||||
$workerUrl = rtrim(config('speech2text.transcription.worker_url'), '/');
|
||||
|
||||
$response = Http::timeout(540)
|
||||
->attach(
|
||||
'audio',
|
||||
fopen($tmpPath, 'rb'),
|
||||
basename($path),
|
||||
['Content-Type' => $project->mime_type]
|
||||
)
|
||||
->post("{$workerUrl}/transcribe");
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \RuntimeException("Transcription worker mengembalikan HTTP {$response->status()}");
|
||||
}
|
||||
|
||||
$result = $response->json();
|
||||
|
||||
if (! ($result['success'] ?? false)) {
|
||||
throw new \RuntimeException($result['error'] ?? 'Transcription gagal tanpa mesej ralat.');
|
||||
}
|
||||
|
||||
$project->update([
|
||||
'transcription_status' => 'completed',
|
||||
'transcript_text' => $result['transcript'] ?? '',
|
||||
'transcript_confidence' => $result['confidence'] ?? null,
|
||||
'duration_seconds' => $result['duration_seconds'] ?? null,
|
||||
'error_message' => null,
|
||||
'processed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->auditLog('transcription_completed', [
|
||||
'duration_seconds' => $result['duration_seconds'] ?? null,
|
||||
'engine' => config('speech2text.transcription.engine'),
|
||||
]);
|
||||
|
||||
// Optional Ollama post-processing
|
||||
if (config('speech2text.ollama.enabled') && $project->transcript_text) {
|
||||
OllamaPostProcessJob::dispatch($project);
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("TranscribeAudioJob gagal untuk project #{$project->id}: {$e->getMessage()}");
|
||||
|
||||
$project->update([
|
||||
'transcription_status' => 'failed',
|
||||
'error_message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->auditLog('transcription_failed', ['error' => $e->getMessage()]);
|
||||
|
||||
$this->fail($e);
|
||||
|
||||
} finally {
|
||||
if ($tmpPath && file_exists($tmpPath)) {
|
||||
@unlink($tmpPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function auditLog(string $action, array $extra = []): void
|
||||
{
|
||||
AuditLog::create([
|
||||
'actor_user_id' => null,
|
||||
'actor_role' => 'system',
|
||||
'action' => $action,
|
||||
'project_id' => $this->project->id,
|
||||
'new_values' => array_merge(['project_uuid' => $this->project->uuid], $extra),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
46
app/Models/AuditLog.php
Normal file
46
app/Models/AuditLog.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AuditLog extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'actor_user_id',
|
||||
'actor_role',
|
||||
'action',
|
||||
'subject_type',
|
||||
'subject_id',
|
||||
'target_user_id',
|
||||
'project_id',
|
||||
'old_values',
|
||||
'new_values',
|
||||
'justification',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'old_values' => 'array',
|
||||
'new_values' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function actor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'actor_user_id');
|
||||
}
|
||||
|
||||
public function targetUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'target_user_id');
|
||||
}
|
||||
}
|
||||
24
app/Models/Department.php
Normal file
24
app/Models/Department.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Department extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'code', 'is_active'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean'];
|
||||
}
|
||||
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class);
|
||||
}
|
||||
}
|
||||
26
app/Models/ProjectCollaborator.php
Normal file
26
app/Models/ProjectCollaborator.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProjectCollaborator extends Model
|
||||
{
|
||||
protected $fillable = ['project_id', 'user_id', 'role', 'added_by'];
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TranscriptionProject::class, 'project_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function addedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'added_by');
|
||||
}
|
||||
}
|
||||
24
app/Models/ProjectComment.php
Normal file
24
app/Models/ProjectComment.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ProjectComment extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = ['project_id', 'user_id', 'message'];
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TranscriptionProject::class, 'project_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
36
app/Models/TranscriptVersion.php
Normal file
36
app/Models/TranscriptVersion.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TranscriptVersion extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'project_id',
|
||||
'edited_by',
|
||||
'version_number',
|
||||
'old_text',
|
||||
'new_text',
|
||||
'change_summary',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['created_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TranscriptionProject::class, 'project_id');
|
||||
}
|
||||
|
||||
public function editor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'edited_by');
|
||||
}
|
||||
}
|
||||
153
app/Models/TranscriptionProject.php
Normal file
153
app/Models/TranscriptionProject.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TranscriptionProject extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'title',
|
||||
'description',
|
||||
'owner_user_id',
|
||||
'original_filename',
|
||||
'stored_audio_path',
|
||||
'mime_type',
|
||||
'file_size',
|
||||
'duration_seconds',
|
||||
'language',
|
||||
'transcription_status',
|
||||
'transcription_engine',
|
||||
'transcript_text',
|
||||
'transcript_confidence',
|
||||
'error_message',
|
||||
'processed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'transcript_text' => 'encrypted', // enkripsi kandungan sensitif
|
||||
'transcript_confidence' => 'float',
|
||||
'processed_at' => 'datetime',
|
||||
'file_size' => 'integer',
|
||||
'duration_seconds' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $project) {
|
||||
if (empty($project->uuid)) {
|
||||
$project->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Route model binding gunakan uuid
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Relationships
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'owner_user_id');
|
||||
}
|
||||
|
||||
public function collaborators(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'project_collaborators', 'project_id', 'user_id')
|
||||
->withPivot('role', 'added_by')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function projectCollaborators(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectCollaborator::class, 'project_id');
|
||||
}
|
||||
|
||||
public function transcriptVersions(): HasMany
|
||||
{
|
||||
return $this->hasMany(TranscriptVersion::class, 'project_id')->orderByDesc('version_number');
|
||||
}
|
||||
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectComment::class, 'project_id')->latest();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function isOwnedBy(User $user): bool
|
||||
{
|
||||
return $this->owner_user_id === $user->id;
|
||||
}
|
||||
|
||||
public function hasCollaborator(User $user): bool
|
||||
{
|
||||
return $this->collaborators()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
public function isAccessibleBy(User $user): bool
|
||||
{
|
||||
return $this->isOwnedBy($user) || $this->hasCollaborator($user);
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->transcription_status === 'pending';
|
||||
}
|
||||
|
||||
public function isProcessing(): bool
|
||||
{
|
||||
return $this->transcription_status === 'processing';
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->transcription_status === 'completed';
|
||||
}
|
||||
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->transcription_status === 'failed';
|
||||
}
|
||||
|
||||
public function fileSizeForHumans(): string
|
||||
{
|
||||
$bytes = $this->file_size;
|
||||
if ($bytes >= 1073741824) {
|
||||
return number_format($bytes / 1073741824, 2) . ' GB';
|
||||
}
|
||||
if ($bytes >= 1048576) {
|
||||
return number_format($bytes / 1048576, 2) . ' MB';
|
||||
}
|
||||
return number_format($bytes / 1024, 2) . ' KB';
|
||||
}
|
||||
|
||||
public function durationForHumans(): ?string
|
||||
{
|
||||
if (! $this->duration_seconds) {
|
||||
return null;
|
||||
}
|
||||
$m = intdiv($this->duration_seconds, 60);
|
||||
$s = $this->duration_seconds % 60;
|
||||
return sprintf('%d:%02d', $m, $s);
|
||||
}
|
||||
}
|
||||
106
app/Models/User.php
Normal file
106
app/Models/User.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasFactory, Notifiable, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'role',
|
||||
'department_id',
|
||||
'is_active',
|
||||
'last_login_at',
|
||||
'email_verified_at',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'last_login_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Role helpers
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === 'admin';
|
||||
}
|
||||
|
||||
public function isUser(): bool
|
||||
{
|
||||
return $this->role === 'user';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return (bool) $this->is_active;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Relationships
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function department(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class);
|
||||
}
|
||||
|
||||
public function ownedProjects(): HasMany
|
||||
{
|
||||
return $this->hasMany(TranscriptionProject::class, 'owner_user_id');
|
||||
}
|
||||
|
||||
public function collaboratingProjects(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
TranscriptionProject::class,
|
||||
'project_collaborators',
|
||||
'user_id',
|
||||
'project_id'
|
||||
)->withPivot('role', 'added_by')->withTimestamps();
|
||||
}
|
||||
|
||||
public function auditActionsPerformed(): HasMany
|
||||
{
|
||||
return $this->hasMany(AuditLog::class, 'actor_user_id');
|
||||
}
|
||||
|
||||
public function auditActionsTargeted(): HasMany
|
||||
{
|
||||
return $this->hasMany(AuditLog::class, 'target_user_id');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Usage check (for safe delete)
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function hasUsage(): bool
|
||||
{
|
||||
return $this->ownedProjects()->withTrashed()->exists()
|
||||
|| $this->collaboratingProjects()->exists()
|
||||
|| AuditLog::where('actor_user_id', $this->id)->exists();
|
||||
}
|
||||
}
|
||||
26
app/Policies/CommentPolicy.php
Normal file
26
app/Policies/CommentPolicy.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\ProjectComment;
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Models\User;
|
||||
|
||||
class CommentPolicy
|
||||
{
|
||||
public function view(User $user, TranscriptionProject $project): bool
|
||||
{
|
||||
return $project->isAccessibleBy($user);
|
||||
}
|
||||
|
||||
public function create(User $user, TranscriptionProject $project): bool
|
||||
{
|
||||
return $project->isAccessibleBy($user);
|
||||
}
|
||||
|
||||
public function delete(User $user, ProjectComment $comment): bool
|
||||
{
|
||||
$project = $comment->project;
|
||||
return $comment->user_id === $user->id || $project->isOwnedBy($user);
|
||||
}
|
||||
}
|
||||
26
app/Policies/TranscriptVersionPolicy.php
Normal file
26
app/Policies/TranscriptVersionPolicy.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\TranscriptVersion;
|
||||
use App\Models\User;
|
||||
|
||||
class TranscriptVersionPolicy
|
||||
{
|
||||
public function view(User $user, TranscriptVersion $version): bool
|
||||
{
|
||||
return $version->project->isAccessibleBy($user);
|
||||
}
|
||||
|
||||
public function restore(User $user, TranscriptVersion $version): bool
|
||||
{
|
||||
$project = $version->project;
|
||||
if ($project->isOwnedBy($user)) {
|
||||
return true;
|
||||
}
|
||||
return $project->collaborators()
|
||||
->where('user_id', $user->id)
|
||||
->wherePivot('role', 'editor')
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
90
app/Policies/TranscriptionProjectPolicy.php
Normal file
90
app/Policies/TranscriptionProjectPolicy.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Models\User;
|
||||
|
||||
class TranscriptionProjectPolicy
|
||||
{
|
||||
// Admin TIDAK boleh view detail (kandungan)
|
||||
public function view(User $user, TranscriptionProject $project): bool
|
||||
{
|
||||
return $project->isAccessibleBy($user);
|
||||
}
|
||||
|
||||
// Admin TIDAK boleh view metadata content — hanya metadata minimum via viewMetadata
|
||||
public function viewMetadata(User $user, TranscriptionProject $project): bool
|
||||
{
|
||||
return $user->isAdmin() || $project->isAccessibleBy($user);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
// Admin juga kakitangan — boleh cipta projek sendiri
|
||||
return $user->isActive();
|
||||
}
|
||||
|
||||
public function update(User $user, TranscriptionProject $project): bool
|
||||
{
|
||||
return $project->isAccessibleBy($user);
|
||||
}
|
||||
|
||||
public function delete(User $user, TranscriptionProject $project): bool
|
||||
{
|
||||
return $project->isOwnedBy($user);
|
||||
}
|
||||
|
||||
public function viewAudio(User $user, TranscriptionProject $project): bool
|
||||
{
|
||||
return $project->isAccessibleBy($user);
|
||||
}
|
||||
|
||||
public function viewTranscript(User $user, TranscriptionProject $project): bool
|
||||
{
|
||||
return $project->isAccessibleBy($user);
|
||||
}
|
||||
|
||||
public function editTranscript(User $user, TranscriptionProject $project): bool
|
||||
{
|
||||
if ($project->isOwnedBy($user)) {
|
||||
return true;
|
||||
}
|
||||
// Viewer collaborator tidak boleh edit — editor sahaja
|
||||
return $project->collaborators()
|
||||
->where('user_id', $user->id)
|
||||
->wherePivot('role', 'editor')
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function manageCollaborators(User $user, TranscriptionProject $project): bool
|
||||
{
|
||||
return $project->isOwnedBy($user);
|
||||
}
|
||||
|
||||
public function retryTranscription(User $user, TranscriptionProject $project): bool
|
||||
{
|
||||
return $project->isOwnedBy($user) && $project->isFailed();
|
||||
}
|
||||
|
||||
public function transferOwner(User $user, TranscriptionProject $project): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
public function viewVersionHistory(User $user, TranscriptionProject $project): bool
|
||||
{
|
||||
return $project->isAccessibleBy($user);
|
||||
}
|
||||
|
||||
public function restoreVersion(User $user, TranscriptionProject $project): bool
|
||||
{
|
||||
if ($project->isOwnedBy($user)) {
|
||||
return true;
|
||||
}
|
||||
return $project->collaborators()
|
||||
->where('user_id', $user->id)
|
||||
->wherePivot('role', 'editor')
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
45
app/Policies/UserPolicy.php
Normal file
45
app/Policies/UserPolicy.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class UserPolicy
|
||||
{
|
||||
public function viewAny(User $actor): bool
|
||||
{
|
||||
return $actor->isAdmin();
|
||||
}
|
||||
|
||||
public function create(User $actor): bool
|
||||
{
|
||||
return $actor->isAdmin();
|
||||
}
|
||||
|
||||
public function update(User $actor, User $target): bool
|
||||
{
|
||||
return $actor->isAdmin() && $actor->id !== $target->id;
|
||||
}
|
||||
|
||||
public function delete(User $actor, User $target): bool
|
||||
{
|
||||
return $actor->isAdmin()
|
||||
&& $actor->id !== $target->id
|
||||
&& ! $target->hasUsage();
|
||||
}
|
||||
|
||||
public function activate(User $actor, User $target): bool
|
||||
{
|
||||
return $actor->isAdmin() && $actor->id !== $target->id;
|
||||
}
|
||||
|
||||
public function deactivate(User $actor, User $target): bool
|
||||
{
|
||||
return $actor->isAdmin() && $actor->id !== $target->id;
|
||||
}
|
||||
|
||||
public function changeEmail(User $actor, User $target): bool
|
||||
{
|
||||
return $actor->isAdmin() && $actor->id !== $target->id;
|
||||
}
|
||||
}
|
||||
40
app/Providers/AppServiceProvider.php
Normal file
40
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\ProjectComment;
|
||||
use App\Models\TranscriptVersion;
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Models\User;
|
||||
use App\Policies\CommentPolicy;
|
||||
use App\Policies\TranscriptVersionPolicy;
|
||||
use App\Policies\TranscriptionProjectPolicy;
|
||||
use App\Policies\UserPolicy;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void {}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// Register Policies
|
||||
Gate::policy(User::class, UserPolicy::class);
|
||||
Gate::policy(TranscriptionProject::class, TranscriptionProjectPolicy::class);
|
||||
Gate::policy(TranscriptVersion::class, TranscriptVersionPolicy::class);
|
||||
Gate::policy(ProjectComment::class, CommentPolicy::class);
|
||||
|
||||
// Rate Limiters
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->input('email') . '|' . $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('upload', function (Request $request) {
|
||||
return Limit::perHour(10)->by($request->user()?->id ?: $request->ip());
|
||||
});
|
||||
}
|
||||
}
|
||||
77
app/Rules/ValidAudioMagicBytes.php
Normal file
77
app/Rules/ValidAudioMagicBytes.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class ValidAudioMagicBytes implements ValidationRule
|
||||
{
|
||||
// Signatures checked at byte offset 0
|
||||
private const SIGNATURES = [
|
||||
[0xFF, 0xFB], // MP3 MPEG-1 Layer 3
|
||||
[0xFF, 0xF3], // MP3 MPEG-2 Layer 3
|
||||
[0xFF, 0xF2], // MP3 MPEG-2.5 Layer 3
|
||||
[0xFF, 0xF1], // AAC ADTS MPEG-4
|
||||
[0xFF, 0xF9], // AAC ADTS MPEG-2
|
||||
[0x49, 0x44, 0x33], // MP3 with ID3v2 tag
|
||||
[0x52, 0x49, 0x46, 0x46], // WAV (RIFF header)
|
||||
[0x4F, 0x67, 0x67, 0x53], // OGG (OggS)
|
||||
[0x66, 0x4C, 0x61, 0x43], // FLAC (fLaC)
|
||||
[0x1A, 0x45, 0xDF, 0xA3], // WebM / MKV
|
||||
];
|
||||
|
||||
// MP4/M4A: ISO Base Media file — 'ftyp' box starts at byte offset 4
|
||||
private const MP4_SIGNATURE = [0x66, 0x74, 0x79, 0x70]; // 'ftyp'
|
||||
private const MP4_OFFSET = 4;
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! ($value instanceof UploadedFile) || ! $value->isValid()) {
|
||||
$fail('Fail audio tidak sah.');
|
||||
return;
|
||||
}
|
||||
|
||||
$handle = @fopen($value->getRealPath(), 'rb');
|
||||
if ($handle === false) {
|
||||
$fail('Fail audio tidak dapat dibaca.');
|
||||
return;
|
||||
}
|
||||
|
||||
$header = fread($handle, 12);
|
||||
fclose($handle);
|
||||
|
||||
if (strlen($header) < 4) {
|
||||
$fail('Fail audio terlalu kecil atau rosak.');
|
||||
return;
|
||||
}
|
||||
|
||||
$bytes = array_values(unpack('C*', $header));
|
||||
|
||||
foreach (self::SIGNATURES as $sig) {
|
||||
if ($this->matchesAt($bytes, $sig, 0)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check MP4/M4A: need at least 8 bytes
|
||||
if (count($bytes) >= self::MP4_OFFSET + count(self::MP4_SIGNATURE)) {
|
||||
if ($this->matchesAt($bytes, self::MP4_SIGNATURE, self::MP4_OFFSET)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$fail('Format fail audio tidak sah. Kandungan fail tidak menepati format audio yang dibenarkan.');
|
||||
}
|
||||
|
||||
private function matchesAt(array $bytes, array $signature, int $offset): bool
|
||||
{
|
||||
foreach ($signature as $i => $expected) {
|
||||
if (($bytes[$offset + $i] ?? -1) !== $expected) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
35
app/Services/AuditLogService.php
Normal file
35
app/Services/AuditLogService.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AuditLogService
|
||||
{
|
||||
public function __construct(private readonly Request $request) {}
|
||||
|
||||
public function log(
|
||||
string $action,
|
||||
array $options = []
|
||||
): AuditLog {
|
||||
$actor = Auth::user();
|
||||
|
||||
return AuditLog::create([
|
||||
'actor_user_id' => $actor?->id ?? $options['actor_user_id'] ?? null,
|
||||
'actor_role' => $actor?->role ?? $options['actor_role'] ?? 'system',
|
||||
'action' => $action,
|
||||
'subject_type' => $options['subject_type'] ?? null,
|
||||
'subject_id' => $options['subject_id'] ?? null,
|
||||
'target_user_id'=> $options['target_user_id'] ?? null,
|
||||
'project_id' => $options['project_id'] ?? null,
|
||||
'old_values' => $options['old_values'] ?? null,
|
||||
'new_values' => $options['new_values'] ?? null,
|
||||
'justification' => $options['justification'] ?? null,
|
||||
'ip_address' => $this->request->ip(),
|
||||
'user_agent' => $this->request->userAgent(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
71
app/Services/OllamaService.php
Normal file
71
app/Services/OllamaService.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class OllamaService
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $model;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->baseUrl = rtrim(config('speech2text.ollama.base_url'), '/');
|
||||
$this->model = config('speech2text.ollama.model');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean and improve raw transcript text using a local Ollama model.
|
||||
* Returns corrected text, or null if Ollama is unavailable.
|
||||
*/
|
||||
public function cleanTranscript(string $rawText, string $language = 'ms'): ?string
|
||||
{
|
||||
$langLabel = $language === 'ms' ? 'Bahasa Melayu' : 'English';
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
Anda adalah pembantu penyuntingan teks transkripsi automatik dalam {$langLabel}.
|
||||
|
||||
Tugas anda:
|
||||
1. Betulkan ejaan dan tanda baca
|
||||
2. Pisahkan ayat dengan betul
|
||||
3. Kekalkan maksud asal — JANGAN tukar kandungan atau tambah maklumat baru
|
||||
4. Kembalikan hanya teks yang telah diperbetulkan, tiada penjelasan tambahan
|
||||
|
||||
Teks transkripsi:
|
||||
{$rawText}
|
||||
PROMPT;
|
||||
|
||||
try {
|
||||
$response = Http::timeout(120)
|
||||
->post("{$this->baseUrl}/api/generate", [
|
||||
'model' => $this->model,
|
||||
'prompt' => $prompt,
|
||||
'stream' => false,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning("OllamaService: HTTP {$response->status()} dari {$this->baseUrl}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = trim($response->json('response') ?? '');
|
||||
|
||||
return $text !== '' ? $text : null;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("OllamaService tidak tersedia: {$e->getMessage()}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
try {
|
||||
return Http::timeout(5)->get("{$this->baseUrl}/api/tags")->successful();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
app/Services/StorageService.php
Normal file
45
app/Services/StorageService.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class StorageService
|
||||
{
|
||||
private const DISK = 'private';
|
||||
|
||||
public function storeAudio(UploadedFile $file, string $uuid): string
|
||||
{
|
||||
$ext = $file->getClientOriginalExtension();
|
||||
$filename = Str::random(40) . '.' . strtolower($ext);
|
||||
$dir = "transcriptions/{$uuid}/audio";
|
||||
|
||||
Storage::disk(self::DISK)->putFileAs($dir, $file, $filename);
|
||||
|
||||
return "{$dir}/{$filename}";
|
||||
}
|
||||
|
||||
public function delete(string $path): void
|
||||
{
|
||||
if (Storage::disk(self::DISK)->exists($path)) {
|
||||
Storage::disk(self::DISK)->delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function exists(string $path): bool
|
||||
{
|
||||
return Storage::disk(self::DISK)->exists($path);
|
||||
}
|
||||
|
||||
public function size(string $path): int
|
||||
{
|
||||
return Storage::disk(self::DISK)->size($path);
|
||||
}
|
||||
|
||||
public function readStream(string $path)
|
||||
{
|
||||
return Storage::disk(self::DISK)->readStream($path);
|
||||
}
|
||||
}
|
||||
18
artisan
Normal file
18
artisan
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
31
bootstrap/app.php
Normal file
31
bootstrap/app.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\EnsureAdmin;
|
||||
use App\Http\Middleware\EnsureRegularUser;
|
||||
use App\Http\Middleware\EnsureUserIsActive;
|
||||
use App\Http\Middleware\SecurityHeaders;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'active' => EnsureUserIsActive::class,
|
||||
'admin' => EnsureAdmin::class,
|
||||
'user.role' => EnsureRegularUser::class,
|
||||
]);
|
||||
|
||||
$middleware->web(append: [
|
||||
SecurityHeaders::class,
|
||||
EnsureUserIsActive::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
})->create();
|
||||
2
bootstrap/cache/.gitignore
vendored
Normal file
2
bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
7
bootstrap/providers.php
Normal file
7
bootstrap/providers.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
];
|
||||
86
composer.json
Normal file
86
composer.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/tinker": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/pail": "^1.2.5",
|
||||
"laravel/pao": "^1.0.6",
|
||||
"laravel/pint": "^1.27",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^12.5.12"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"setup": [
|
||||
"composer install",
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install --ignore-scripts",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi @no_additional_args",
|
||||
"@php artisan test"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
8274
composer.lock
generated
Normal file
8274
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
126
config/app.php
Normal file
126
config/app.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application, which will be used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| other UI elements where an application name needs to be displayed.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the "environment" your application is currently
|
||||
| running in. This may determine how you prefer to configure various
|
||||
| services the application utilizes. Set this in your ".env" file.
|
||||
|
|
||||
*/
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When your application is in debug mode, detailed error messages with
|
||||
| stack traces will be shown on every error that occurs within your
|
||||
| application. If disabled, a simple generic error page is shown.
|
||||
|
|
||||
*/
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used by the console to properly generate URLs when using
|
||||
| the Artisan command line tool. You should set this to the root of
|
||||
| the application so that it's available within Artisan commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default timezone for your application, which
|
||||
| will be used by the PHP date and date-time functions. The timezone
|
||||
| is set to "UTC" by default as it is suitable for most use cases.
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The application locale determines the default locale that will be used
|
||||
| by Laravel's translation / localization methods. This option can be
|
||||
| set to any locale for which you plan to have translation strings.
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is utilized by Laravel's encryption services and should be set
|
||||
| to a random, 32 character string to ensure that all encrypted values
|
||||
| are secure. You should do this prior to deploying the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
'previous_keys' => [
|
||||
...array_filter(
|
||||
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||
),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maintenance Mode Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options determine the driver used to determine and
|
||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||
| allow maintenance mode to be controlled across multiple machines.
|
||||
|
|
||||
| Supported drivers: "file", "cache"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
];
|
||||
117
config/auth.php
Normal file
117
config/auth.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_GUARD', 'web'),
|
||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| which utilizes session storage plus the Eloquent user provider.
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| If you have multiple user tables or models you may configure multiple
|
||||
| providers to represent the model / table. These providers may then
|
||||
| be assigned to any extra authentication guards you have defined.
|
||||
|
|
||||
| Supported: "database", "eloquent"
|
||||
|
|
||||
*/
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options specify the behavior of Laravel's password
|
||||
| reset functionality, including the table utilized for token storage
|
||||
| and the user provider that is invoked to actually retrieve users.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the number of seconds before a password confirmation
|
||||
| window expires and users are asked to re-enter their password via the
|
||||
| confirmation screen. By default, the timeout lasts for three hours.
|
||||
|
|
||||
*/
|
||||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
];
|
||||
136
config/cache.php
Normal file
136
config/cache.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default cache store that will be used by the
|
||||
| framework. This connection is utilized if another isn't explicitly
|
||||
| specified when running a cache operation inside the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_STORE', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Stores
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the cache "stores" for your application as
|
||||
| well as their drivers. You may even define multiple stores for the
|
||||
| same cache driver to group types of items stored in your caches.
|
||||
|
|
||||
| Supported drivers: "array", "database", "file", "memcached",
|
||||
| "redis", "dynamodb", "storage", "octane",
|
||||
| "session", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'stores' => [
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_CACHE_CONNECTION'),
|
||||
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||
],
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
],
|
||||
|
||||
'storage' => [
|
||||
'driver' => 'storage',
|
||||
'disk' => env('CACHE_STORAGE_DISK'),
|
||||
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||
'sasl' => [
|
||||
env('MEMCACHED_USERNAME'),
|
||||
env('MEMCACHED_PASSWORD'),
|
||||
],
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||
'port' => env('MEMCACHED_PORT', 11211),
|
||||
'weight' => 100,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||
],
|
||||
|
||||
'dynamodb' => [
|
||||
'driver' => 'dynamodb',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||
],
|
||||
|
||||
'octane' => [
|
||||
'driver' => 'octane',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'stores' => [
|
||||
'database',
|
||||
'array',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||
| stores, there might be other applications using the same cache. For
|
||||
| that reason, you may prefix every cache key to avoid collisions.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Serializable Classes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the classes that can be unserialized from cache
|
||||
| storage. By default, no PHP classes will be unserialized from your
|
||||
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
||||
|
|
||||
*/
|
||||
|
||||
'serializable_classes' => false,
|
||||
|
||||
];
|
||||
184
config/database.php
Normal file
184
config/database.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Pdo\Mysql;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for database operations. This is
|
||||
| the connection which will be utilized unless another connection
|
||||
| is explicitly specified when you execute a query / statement.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below are all of the database connections defined for your application.
|
||||
| An example configuration is provided for each database system which
|
||||
| is supported by Laravel. You're free to add / remove connections.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'synchronous' => null,
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Migration Repository Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This table keeps track of all the migrations that have already run for
|
||||
| your application. Using this information, we can determine which of
|
||||
| the migrations on disk haven't actually been run on the database.
|
||||
|
|
||||
*/
|
||||
|
||||
'migrations' => [
|
||||
'table' => 'migrations',
|
||||
'update_date_on_publish' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redis Databases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Redis is an open source, fast, and advanced key-value store that also
|
||||
| provides a richer body of commands than a typical key-value system
|
||||
| such as Memcached. You may define your connection settings here.
|
||||
|
|
||||
*/
|
||||
|
||||
'redis' => [
|
||||
|
||||
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
89
config/filesystems.php
Normal file
89
config/filesystems.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Filesystem Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application for file storage.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below you may configure as many filesystem disks as necessary, and you
|
||||
| may even configure multiple disks for the same driver. Examples for
|
||||
| most supported storage drivers are configured here for reference.
|
||||
|
|
||||
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||
|
|
||||
*/
|
||||
|
||||
'disks' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'serve' => false, // jangan serve direct — mesti melalui controller
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
// Disk khas untuk audio dan transcript (private, tidak accessible web)
|
||||
'private' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'serve' => false,
|
||||
'throw' => true,
|
||||
'report' => true,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Symbolic Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the symbolic links that will be created when the
|
||||
| `storage:link` Artisan command is executed. The array keys should be
|
||||
| the locations of the links and the values should be their targets.
|
||||
|
|
||||
*/
|
||||
|
||||
'links' => [
|
||||
public_path('storage') => storage_path('app/public'),
|
||||
],
|
||||
|
||||
];
|
||||
132
config/logging.php
Normal file
132
config/logging.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default log channel that is utilized to write
|
||||
| messages to your logs. The value provided here should match one of
|
||||
| the channels present in the list of "channels" configured below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('LOG_CHANNEL', 'stack'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deprecations Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the log channel that should be used to log warnings
|
||||
| regarding deprecated PHP and library features. This allows you to get
|
||||
| your application ready for upcoming major versions of dependencies.
|
||||
|
|
||||
*/
|
||||
|
||||
'deprecations' => [
|
||||
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Log Channels
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the log channels for your application. Laravel
|
||||
| utilizes the Monolog PHP logging library, which includes a variety
|
||||
| of powerful log handlers and formatters that you're free to use.
|
||||
|
|
||||
| Available drivers: "single", "daily", "slack", "syslog",
|
||||
| "errorlog", "monolog", "custom", "stack"
|
||||
|
|
||||
*/
|
||||
|
||||
'channels' => [
|
||||
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||
'ignore_exceptions' => false,
|
||||
],
|
||||
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
|
||||
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||
'level' => env('LOG_LEVEL', 'critical'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'papertrail' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||
'handler_with' => [
|
||||
'host' => env('PAPERTRAIL_URL'),
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => StreamHandler::class,
|
||||
'handler_with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'syslog' => [
|
||||
'driver' => 'syslog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'errorlog' => [
|
||||
'driver' => 'errorlog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
],
|
||||
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
118
config/mail.php
Normal file
118
config/mail.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Mailer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default mailer that is used to send all email
|
||||
| messages unless another mailer is explicitly specified when sending
|
||||
| the message. All additional mailers can be configured within the
|
||||
| "mailers" array. Examples of each type of mailer are provided.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'log'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mailer Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure all of the mailers used by your application plus
|
||||
| their respective settings. Several examples have been configured for
|
||||
| you and you are free to add your own as your application requires.
|
||||
|
|
||||
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||
| when delivering an email. You may specify which one you're using for
|
||||
| your mailers below. You may also add additional mailers if needed.
|
||||
|
|
||||
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||
| "postmark", "resend", "log", "array",
|
||||
| "failover", "roundrobin"
|
||||
|
|
||||
*/
|
||||
|
||||
'mailers' => [
|
||||
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => env('MAIL_SCHEME'),
|
||||
'url' => env('MAIL_URL'),
|
||||
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||
'port' => env('MAIL_PORT', 2525),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'transport' => 'ses',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'transport' => 'postmark',
|
||||
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||
// 'client' => [
|
||||
// 'timeout' => 5,
|
||||
// ],
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'transport' => 'resend',
|
||||
],
|
||||
|
||||
'sendmail' => [
|
||||
'transport' => 'sendmail',
|
||||
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'transport' => 'log',
|
||||
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'transport' => 'array',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'transport' => 'failover',
|
||||
'mailers' => [
|
||||
'smtp',
|
||||
'log',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
'roundrobin' => [
|
||||
'transport' => 'roundrobin',
|
||||
'mailers' => [
|
||||
'ses',
|
||||
'postmark',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global "From" Address
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may wish for all emails sent by your application to be sent from
|
||||
| the same address. Here you may specify a name and address that is
|
||||
| used globally for all emails that are sent by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
||||
],
|
||||
|
||||
];
|
||||
129
config/queue.php
Normal file
129
config/queue.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Queue Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Laravel's queue supports a variety of backends via a single, unified
|
||||
| API, giving you convenient access to each backend using identical
|
||||
| syntax for each. The default queue connection is defined below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the connection options for every queue backend
|
||||
| used by your application. An example configuration is provided for
|
||||
| each backend supported by Laravel. You're also free to add more.
|
||||
|
|
||||
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||
| "deferred", "background", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sync' => [
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => 0,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'sqs' => [
|
||||
'driver' => 'sqs',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||
'queue' => env('SQS_QUEUE', 'default'),
|
||||
'suffix' => env('SQS_SUFFIX'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'deferred' => [
|
||||
'driver' => 'deferred',
|
||||
],
|
||||
|
||||
'background' => [
|
||||
'driver' => 'background',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'connections' => [
|
||||
'database',
|
||||
'deferred',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Batching
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following options configure the database and table that store job
|
||||
| batching information. These options can be updated to any database
|
||||
| connection and table which has been defined by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'batching' => [
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'job_batches',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Failed Queue Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These options configure the behavior of failed queue job logging so you
|
||||
| can control how and where failed jobs are stored. Laravel ships with
|
||||
| support for storing failed jobs in a simple file or in a database.
|
||||
|
|
||||
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => [
|
||||
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
];
|
||||
38
config/services.php
Normal file
38
config/services.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file is for storing the credentials for third party services such
|
||||
| as Mailgun, Postmark, AWS and more. This file provides the de facto
|
||||
| location for this type of information, allowing packages to have
|
||||
| a conventional file to locate the various service credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'postmark' => [
|
||||
'key' => env('POSTMARK_API_KEY'),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_API_KEY'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'notifications' => [
|
||||
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
233
config/session.php
Normal file
233
config/session.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Session Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines the default session driver that is utilized for
|
||||
| incoming requests. Laravel supports a variety of storage options to
|
||||
| persist session data. Database storage is a great default choice.
|
||||
|
|
||||
| Supported: "file", "cookie", "database", "memcached",
|
||||
| "redis", "dynamodb", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SESSION_DRIVER', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Lifetime
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the number of minutes that you wish the session
|
||||
| to be allowed to remain idle before it expires. If you want them
|
||||
| to expire immediately when the browser is closed then you may
|
||||
| indicate that via the expire_on_close configuration option.
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||
|
||||
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Encryption
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to easily specify that all of your session data
|
||||
| should be encrypted before it's stored. All encryption is performed
|
||||
| automatically by Laravel and you may use the session like normal.
|
||||
|
|
||||
*/
|
||||
|
||||
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session File Location
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the "file" session driver, the session files are placed
|
||||
| on disk. The default storage location is defined here; however, you
|
||||
| are free to provide another location where they should be stored.
|
||||
|
|
||||
*/
|
||||
|
||||
'files' => storage_path('framework/sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" or "redis" session drivers, you may specify a
|
||||
| connection that should be used to manage these sessions. This should
|
||||
| correspond to a connection in your database configuration options.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" session driver, you may specify the table to
|
||||
| be used to store sessions. Of course, a sensible default is defined
|
||||
| for you; however, you're welcome to change this to another table.
|
||||
|
|
||||
*/
|
||||
|
||||
'table' => env('SESSION_TABLE', 'sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using one of the framework's cache driven session backends, you may
|
||||
| define the cache store which should be used to store the session data
|
||||
| between requests. This must match one of your defined cache stores.
|
||||
|
|
||||
| Affects: "dynamodb", "memcached", "redis"
|
||||
|
|
||||
*/
|
||||
|
||||
'store' => env('SESSION_STORE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Sweeping Lottery
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some session drivers must manually sweep their storage location to get
|
||||
| rid of old sessions from storage. Here are the chances that it will
|
||||
| happen on a given request. By default, the odds are 2 out of 100.
|
||||
|
|
||||
*/
|
||||
|
||||
'lottery' => [2, 100],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may change the name of the session cookie that is created by
|
||||
| the framework. Typically, you should not need to change this value
|
||||
| since doing so does not grant a meaningful security improvement.
|
||||
|
|
||||
*/
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The session cookie path determines the path for which the cookie will
|
||||
| be regarded as available. Typically, this will be the root path of
|
||||
| your application, but you're free to change this when necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('SESSION_PATH', '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the domain and subdomains the session cookie is
|
||||
| available to. By default, the cookie will be available to the root
|
||||
| domain without subdomains. Typically, this shouldn't be changed.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('SESSION_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTPS Only Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By setting this option to true, session cookies will only be sent back
|
||||
| to the server if the browser has a HTTPS connection. This will keep
|
||||
| the cookie from being sent to you when it can't be done securely.
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Access Only
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will prevent JavaScript from accessing the
|
||||
| value of the cookie and the cookie will only be accessible through
|
||||
| the HTTP protocol. It's unlikely you should disable this option.
|
||||
|
|
||||
*/
|
||||
|
||||
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Same-Site Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines how your cookies behave when cross-site requests
|
||||
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||
| will set this value to "lax" to permit secure cross-site requests.
|
||||
|
|
||||
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||
|
|
||||
| Supported: "lax", "strict", "none", null
|
||||
|
|
||||
*/
|
||||
|
||||
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Partitioned Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will tie the cookie to the top-level site for
|
||||
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||
|
|
||||
*/
|
||||
|
||||
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Serialization
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the serialization strategy for session data, which
|
||||
| is JSON by default. Setting this to "php" allows the storage of PHP
|
||||
| objects in the session but can make an application vulnerable to
|
||||
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
||||
|
|
||||
| Supported: "json", "php"
|
||||
|
|
||||
*/
|
||||
|
||||
'serialization' => 'json',
|
||||
|
||||
];
|
||||
60
config/speech2text.php
Normal file
60
config/speech2text.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Transcription Engine
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'transcription' => [
|
||||
'engine' => env('TRANSCRIPTION_ENGINE', 'faster-whisper'),
|
||||
'worker_url' => env('TRANSCRIPTION_WORKER_URL', 'http://transcription-worker:8000'),
|
||||
'model' => env('WHISPER_MODEL', 'small'),
|
||||
'language' => env('WHISPER_LANGUAGE', 'ms'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Upload Limits
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'upload' => [
|
||||
'max_mb' => (int) env('PRIVATE_AUDIO_MAX_MB', 200),
|
||||
'allowed_mimes' => [
|
||||
'audio/mpeg',
|
||||
'audio/mp3',
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/mp4',
|
||||
'audio/x-m4a',
|
||||
'audio/aac',
|
||||
'video/mp4',
|
||||
'audio/ogg',
|
||||
'audio/flac',
|
||||
'audio/webm',
|
||||
],
|
||||
'allowed_extensions' => ['mp3', 'wav', 'm4a', 'mp4', 'aac', 'ogg', 'flac', 'webm'],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Ollama — Optional Post-processing
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'ollama' => [
|
||||
'enabled' => env('OLLAMA_ENABLED', false),
|
||||
'base_url' => env('OLLAMA_BASE_URL', 'http://ollama:11434'),
|
||||
'model' => env('OLLAMA_MODEL', 'llama3.1'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Storage Path Templates
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'storage' => [
|
||||
'audio_path' => 'transcriptions/{uuid}/audio',
|
||||
],
|
||||
|
||||
];
|
||||
1
database/.gitignore
vendored
Normal file
1
database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
53
database/factories/TranscriptionProjectFactory.php
Normal file
53
database/factories/TranscriptionProjectFactory.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** @extends Factory<TranscriptionProject> */
|
||||
class TranscriptionProjectFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'title' => fake()->sentence(4),
|
||||
'description' => fake()->optional()->sentence(),
|
||||
'owner_user_id' => User::factory(),
|
||||
'original_filename' => fake()->word() . '.mp3',
|
||||
'stored_audio_path' => 'transcriptions/' . Str::uuid() . '/audio/' . Str::random(20) . '.mp3',
|
||||
'mime_type' => 'audio/mpeg',
|
||||
'file_size' => fake()->numberBetween(100000, 50000000),
|
||||
'duration_seconds' => fake()->optional()->numberBetween(60, 3600),
|
||||
'language' => 'ms',
|
||||
'transcription_status' => 'pending',
|
||||
'transcription_engine' => 'faster-whisper',
|
||||
'transcript_text' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function completed(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'transcription_status' => 'completed',
|
||||
'transcript_text' => fake()->paragraphs(3, true),
|
||||
'processed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function failed(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'transcription_status' => 'failed',
|
||||
'error_message' => 'Connection refused',
|
||||
]);
|
||||
}
|
||||
|
||||
public function processing(): static
|
||||
{
|
||||
return $this->state(fn () => ['transcription_status' => 'processing']);
|
||||
}
|
||||
}
|
||||
43
database/factories/UserFactory.php
Normal file
43
database/factories/UserFactory.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** @extends Factory<User> */
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
protected static ?string $password;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'role' => 'user',
|
||||
'department_id' => null,
|
||||
'is_active' => true,
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
public function admin(): static
|
||||
{
|
||||
return $this->state(fn () => ['role' => 'admin']);
|
||||
}
|
||||
|
||||
public function inactive(): static
|
||||
{
|
||||
return $this->state(fn () => ['is_active' => false]);
|
||||
}
|
||||
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn () => ['email_verified_at' => null]);
|
||||
}
|
||||
}
|
||||
57
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
57
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('departments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('code', 50)->nullable()->unique();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->enum('role', ['admin', 'user'])->default('user');
|
||||
$table->foreignId('department_id')->nullable()->constrained('departments')->nullOnDelete();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamp('last_login_at')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sessions');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('departments');
|
||||
}
|
||||
};
|
||||
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache');
|
||||
Schema::dropIfExists('cache_locks');
|
||||
}
|
||||
};
|
||||
59
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
59
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedSmallInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
|
||||
Schema::create('job_batches', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('name');
|
||||
$table->integer('total_jobs');
|
||||
$table->integer('pending_jobs');
|
||||
$table->integer('failed_jobs');
|
||||
$table->longText('failed_job_ids');
|
||||
$table->mediumText('options')->nullable();
|
||||
$table->integer('cancelled_at')->nullable();
|
||||
$table->integer('created_at');
|
||||
$table->integer('finished_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->string('connection');
|
||||
$table->string('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
|
||||
$table->index(['connection', 'queue', 'failed_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
Schema::dropIfExists('job_batches');
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('audit_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('actor_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('actor_role', 50)->nullable();
|
||||
$table->string('action', 100)->index();
|
||||
$table->string('subject_type', 100)->nullable();
|
||||
$table->unsignedBigInteger('subject_id')->nullable();
|
||||
$table->foreignId('target_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->unsignedBigInteger('project_id')->nullable();
|
||||
$table->json('old_values')->nullable();
|
||||
$table->json('new_values')->nullable();
|
||||
$table->text('justification')->nullable();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->timestamp('created_at')->nullable()->index();
|
||||
|
||||
$table->index(['subject_type', 'subject_id']);
|
||||
$table->index('project_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('audit_logs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('transcription_projects', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->string('title');
|
||||
$table->text('description')->nullable();
|
||||
$table->foreignId('owner_user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->string('original_filename', 500);
|
||||
$table->string('stored_audio_path', 1000);
|
||||
$table->string('mime_type', 100);
|
||||
$table->unsignedBigInteger('file_size');
|
||||
$table->unsignedInteger('duration_seconds')->nullable();
|
||||
$table->string('language', 10)->default('ms');
|
||||
$table->enum('transcription_status', ['pending', 'processing', 'completed', 'failed'])->default('pending')->index();
|
||||
$table->string('transcription_engine', 50)->nullable();
|
||||
$table->longText('transcript_text')->nullable(); // encrypted via model cast
|
||||
$table->decimal('transcript_confidence', 5, 4)->nullable();
|
||||
$table->text('error_message')->nullable();
|
||||
$table->timestamp('processed_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index('owner_user_id');
|
||||
});
|
||||
|
||||
Schema::create('project_collaborators', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('project_id')->constrained('transcription_projects')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->enum('role', ['editor', 'viewer'])->default('editor');
|
||||
$table->foreignId('added_by')->constrained('users')->restrictOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['project_id', 'user_id']);
|
||||
});
|
||||
|
||||
Schema::create('transcript_versions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('project_id')->constrained('transcription_projects')->cascadeOnDelete();
|
||||
$table->foreignId('edited_by')->constrained('users')->restrictOnDelete();
|
||||
$table->unsignedInteger('version_number');
|
||||
$table->longText('old_text')->nullable();
|
||||
$table->longText('new_text');
|
||||
$table->string('change_summary', 500)->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
|
||||
$table->index(['project_id', 'version_number']);
|
||||
});
|
||||
|
||||
Schema::create('project_comments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('project_id')->constrained('transcription_projects')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->text('message');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('project_comments');
|
||||
Schema::dropIfExists('transcript_versions');
|
||||
Schema::dropIfExists('project_collaborators');
|
||||
Schema::dropIfExists('transcription_projects');
|
||||
}
|
||||
};
|
||||
41
database/seeders/AdminUserSeeder.php
Normal file
41
database/seeders/AdminUserSeeder.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class AdminUserSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$name = env('ADMIN_DEFAULT_NAME');
|
||||
$email = env('ADMIN_DEFAULT_EMAIL');
|
||||
$password = env('ADMIN_DEFAULT_PASSWORD');
|
||||
|
||||
if (! $name || ! $email || ! $password) {
|
||||
$this->command->warn(
|
||||
'Admin default tidak dicipta. Sila isi ADMIN_DEFAULT_NAME, ADMIN_DEFAULT_EMAIL, dan ADMIN_DEFAULT_PASSWORD dalam .env'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$admin = User::firstOrCreate(
|
||||
['email' => $email],
|
||||
[
|
||||
'name' => $name,
|
||||
'password' => Hash::make($password),
|
||||
'role' => 'admin',
|
||||
'is_active' => true,
|
||||
'email_verified_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
if ($admin->wasRecentlyCreated) {
|
||||
$this->command->info("Admin '{$name}' ({$email}) berjaya dicipta.");
|
||||
} else {
|
||||
$this->command->info("Admin '{$email}' sudah wujud. Tiada perubahan.");
|
||||
}
|
||||
}
|
||||
}
|
||||
16
database/seeders/DatabaseSeeder.php
Normal file
16
database/seeders/DatabaseSeeder.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$this->call([
|
||||
DepartmentSeeder::class,
|
||||
AdminUserSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
29
database/seeders/DepartmentSeeder.php
Normal file
29
database/seeders/DepartmentSeeder.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Department;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DepartmentSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$departments = [
|
||||
['name' => 'Jabatan Pentadbiran', 'code' => 'JPA'],
|
||||
['name' => 'Jabatan Kewangan', 'code' => 'JKW'],
|
||||
['name' => 'Jabatan Kejuruteraan', 'code' => 'JKJ'],
|
||||
['name' => 'Jabatan Perancangan Bandar', 'code' => 'JPB'],
|
||||
['name' => 'Jabatan Kesihatan Persekitaran', 'code' => 'JKP'],
|
||||
['name' => 'Jabatan Pelesenan', 'code' => 'JPL'],
|
||||
['name' => 'Jabatan Undang-Undang', 'code' => 'JUU'],
|
||||
['name' => 'Jabatan Teknologi Maklumat', 'code' => 'JTM'],
|
||||
['name' => 'Jabatan Landskap & Rekreasi', 'code' => 'JLR'],
|
||||
['name' => 'Jabatan Penguatkuasaan', 'code' => 'JPK'],
|
||||
];
|
||||
|
||||
foreach ($departments as $dept) {
|
||||
Department::firstOrCreate(['code' => $dept['code']], $dept);
|
||||
}
|
||||
}
|
||||
}
|
||||
167
docker-compose.yml
Normal file
167
docker-compose.yml
Normal file
@@ -0,0 +1,167 @@
|
||||
name: speech2text-mbip
|
||||
|
||||
services:
|
||||
# ============================================================
|
||||
# Nginx — Web Server
|
||||
# ============================================================
|
||||
nginx:
|
||||
image: nginx:1.25-alpine
|
||||
container_name: speech2text_nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./:/var/www/html:ro
|
||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- app
|
||||
networks:
|
||||
- speech2text_net
|
||||
|
||||
# ============================================================
|
||||
# App — Laravel PHP-FPM
|
||||
# ============================================================
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: speech2text_app
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- APP_ENV=${APP_ENV:-production}
|
||||
volumes:
|
||||
- ./:/var/www/html
|
||||
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
networks:
|
||||
- speech2text_net
|
||||
|
||||
# ============================================================
|
||||
# MySQL 8
|
||||
# ============================================================
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: speech2text_mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-speech2text}
|
||||
MYSQL_USER: ${MYSQL_USER:-speech2text}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
ports:
|
||||
- "127.0.0.1:3307:3306" # host:3307 → container:3306 (elak conflict jika ada projek lain)
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- speech2text_net
|
||||
|
||||
# ============================================================
|
||||
# Redis — Queue & Cache
|
||||
# ============================================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: speech2text_redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --save 60 1 --loglevel warning
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- speech2text_net
|
||||
|
||||
# ============================================================
|
||||
# Queue Worker — Laravel
|
||||
# ============================================================
|
||||
queue-worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: speech2text_queue
|
||||
restart: unless-stopped
|
||||
command: php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600 --memory=256
|
||||
volumes:
|
||||
- ./:/var/www/html
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
networks:
|
||||
- speech2text_net
|
||||
|
||||
# ============================================================
|
||||
# Scheduler — Laravel Cron
|
||||
# ============================================================
|
||||
scheduler:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: speech2text_scheduler
|
||||
restart: unless-stopped
|
||||
command: php artisan schedule:work
|
||||
volumes:
|
||||
- ./:/var/www/html
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
networks:
|
||||
- speech2text_net
|
||||
|
||||
# ============================================================
|
||||
# Transcription Worker — Python FastAPI + faster-whisper
|
||||
# INTERNAL ONLY — tidak boleh diakses dari luar Docker network
|
||||
# ============================================================
|
||||
transcription-worker:
|
||||
build:
|
||||
context: ./docker/transcription-worker
|
||||
dockerfile: Dockerfile
|
||||
container_name: speech2text_transcription
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
WHISPER_MODEL: ${WHISPER_MODEL:-small}
|
||||
WHISPER_LANGUAGE: ${WHISPER_LANGUAGE:-ms}
|
||||
WHISPER_DEVICE: ${WHISPER_DEVICE:-cpu}
|
||||
WHISPER_COMPUTE_TYPE: ${WHISPER_COMPUTE_TYPE:-int8}
|
||||
volumes:
|
||||
- whisper_models:/root/.cache/huggingface # cache model agar tidak download ulang
|
||||
# Port TIDAK di-expose ke host — internal sahaja
|
||||
networks:
|
||||
- speech2text_net
|
||||
|
||||
# ============================================================
|
||||
# Ollama — Optional Local LLM (disabled by default)
|
||||
# Uncomment jika OLLAMA_ENABLED=true
|
||||
# ============================================================
|
||||
# ollama:
|
||||
# image: ollama/ollama
|
||||
# container_name: speech2text_ollama
|
||||
# restart: unless-stopped
|
||||
# volumes:
|
||||
# - ollama_data:/root/.ollama
|
||||
# networks:
|
||||
# - speech2text_net
|
||||
|
||||
networks:
|
||||
speech2text_net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
name: speech2text_mysql_data # nama eksplisit — susah ter-delete secara tidak sengaja
|
||||
labels:
|
||||
com.speech2text.description: "MySQL production data — JANGAN PADAM"
|
||||
redis_data:
|
||||
name: speech2text_redis_data
|
||||
whisper_models:
|
||||
name: speech2text_whisper_models
|
||||
# ollama_data:
|
||||
40
docker/nginx/default.conf
Normal file
40
docker/nginx/default.conf
Normal file
@@ -0,0 +1,40 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /var/www/html/public;
|
||||
index index.php index.html;
|
||||
|
||||
client_max_body_size 210M;
|
||||
|
||||
add_header X-Frame-Options "DENY";
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location = /favicon.ico { access_log off; log_not_found off; }
|
||||
location = /robots.txt { access_log off; log_not_found off; }
|
||||
|
||||
error_page 404 /index.php;
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
fastcgi_read_timeout 300;
|
||||
}
|
||||
|
||||
# Deny access to hidden files
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Deny direct access to storage (private files served via PHP)
|
||||
location ~ ^/storage/ {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
25
docker/php-apache/Dockerfile
Normal file
25
docker/php-apache/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM php:8.5-apache
|
||||
|
||||
ENV APACHE_DOCUMENT_ROOT=/var/www/html/public
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
unzip \
|
||||
libzip-dev \
|
||||
libpng-dev \
|
||||
libjpeg62-turbo-dev \
|
||||
libfreetype6-dev \
|
||||
libicu-dev \
|
||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||
&& docker-php-ext-install pdo_mysql gd intl zip \
|
||||
&& pecl install redis \
|
||||
&& docker-php-ext-enable redis \
|
||||
&& a2enmod rewrite headers \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
COPY docker/php-apache/vhost.conf /etc/apache2/sites-available/000-default.conf
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
RUN chown -R www-data:www-data /var/www/html
|
||||
13
docker/php-apache/vhost.conf
Normal file
13
docker/php-apache/vhost.conf
Normal file
@@ -0,0 +1,13 @@
|
||||
<VirtualHost *:80>
|
||||
ServerAdmin webmaster@localhost
|
||||
DocumentRoot /var/www/html/public
|
||||
|
||||
<Directory /var/www/html/public>
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
Options -Indexes
|
||||
</Directory>
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/access.log combined
|
||||
</VirtualHost>
|
||||
4
docker/php/custom.ini
Normal file
4
docker/php/custom.ini
Normal file
@@ -0,0 +1,4 @@
|
||||
display_errors = On
|
||||
display_startup_errors = Off
|
||||
log_errors = On
|
||||
error_reporting = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED
|
||||
34
docker/php/php.ini
Normal file
34
docker/php/php.ini
Normal file
@@ -0,0 +1,34 @@
|
||||
; Speech2Text MBIP — PHP Configuration
|
||||
|
||||
[PHP]
|
||||
upload_max_filesize = 210M
|
||||
post_max_size = 215M
|
||||
max_execution_time = 300
|
||||
max_input_time = 300
|
||||
memory_limit = 512M
|
||||
|
||||
; Security
|
||||
expose_php = Off
|
||||
display_errors = Off
|
||||
log_errors = On
|
||||
error_log = /var/log/php_errors.log
|
||||
|
||||
; File uploads
|
||||
file_uploads = On
|
||||
upload_tmp_dir = /tmp
|
||||
|
||||
; Session
|
||||
session.cookie_httponly = 1
|
||||
session.cookie_secure = 1
|
||||
session.use_strict_mode = 1
|
||||
session.gc_maxlifetime = 7200
|
||||
|
||||
; Timezone
|
||||
date.timezone = Asia/Kuala_Lumpur
|
||||
|
||||
[opcache]
|
||||
opcache.enable = 1
|
||||
opcache.memory_consumption = 128
|
||||
opcache.max_accelerated_files = 10000
|
||||
opcache.revalidate_freq = 0
|
||||
opcache.validate_timestamps = 0
|
||||
27
docker/supervisor/supervisord.conf
Normal file
27
docker/supervisor/supervisord.conf
Normal file
@@ -0,0 +1,27 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:php-fpm]
|
||||
command=php-fpm
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=5
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:queue-worker]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php /var/www/html/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600 --memory=256
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
numprocs=2
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/log/supervisor/queue-worker.log
|
||||
stopwaitsecs=3600
|
||||
18
docker/transcription-worker/Dockerfile
Normal file
18
docker/transcription-worker/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies for faster-whisper
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
125
docker/transcription-worker/main.py
Normal file
125
docker/transcription-worker/main.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Speech2Text MBIP — Transcription Worker
|
||||
FastAPI service wrapping faster-whisper for Bahasa Melayu transcription.
|
||||
Internal-only service; not exposed to internet.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="Speech2Text Transcription Worker", docs_url=None, redoc_url=None)
|
||||
|
||||
# Config from environment
|
||||
MODEL_SIZE = os.getenv("WHISPER_MODEL", "small")
|
||||
LANGUAGE = os.getenv("WHISPER_LANGUAGE", "ms")
|
||||
DEVICE = os.getenv("WHISPER_DEVICE", "cpu")
|
||||
COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8")
|
||||
|
||||
logger.info(f"Loading Whisper model: {MODEL_SIZE}, device: {DEVICE}, compute: {COMPUTE_TYPE}")
|
||||
|
||||
# Load model once at startup
|
||||
model = WhisperModel(MODEL_SIZE, device=DEVICE, compute_type=COMPUTE_TYPE)
|
||||
|
||||
logger.info("Whisper model loaded successfully.")
|
||||
|
||||
|
||||
class TranscribeResponse(BaseModel):
|
||||
success: bool
|
||||
transcript: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
confidence: Optional[float] = None
|
||||
duration_seconds: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok", "model": MODEL_SIZE, "language": LANGUAGE}
|
||||
|
||||
|
||||
@app.post("/transcribe", response_model=TranscribeResponse)
|
||||
async def transcribe_audio(audio: UploadFile = File(...)):
|
||||
"""
|
||||
Terima fail audio, transkripkan ke teks Bahasa Melayu.
|
||||
Hanya accessible dari dalam Docker network sahaja.
|
||||
"""
|
||||
# Validate content type
|
||||
allowed_types = {
|
||||
"audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav",
|
||||
"audio/mp4", "audio/x-m4a", "audio/aac", "video/mp4",
|
||||
"audio/ogg", "audio/flac", "audio/webm",
|
||||
}
|
||||
|
||||
if audio.content_type and audio.content_type not in allowed_types:
|
||||
logger.warning(f"Rejected content type: {audio.content_type}")
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported audio format: {audio.content_type}")
|
||||
|
||||
# Save to temp file
|
||||
suffix = Path(audio.filename or "audio.mp3").suffix or ".mp3"
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||
content = await audio.read()
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
logger.info(f"Transcribing: {audio.filename}, size: {len(content)} bytes")
|
||||
|
||||
# Run transcription
|
||||
segments, info = model.transcribe(
|
||||
tmp_path,
|
||||
language=LANGUAGE,
|
||||
beam_size=5,
|
||||
vad_filter=True,
|
||||
)
|
||||
|
||||
# Collect segments
|
||||
all_text = []
|
||||
total_confidence = []
|
||||
|
||||
for segment in segments:
|
||||
all_text.append(segment.text.strip())
|
||||
# faster-whisper segments have avg_logprob; convert to 0-1 range
|
||||
if hasattr(segment, "avg_logprob") and segment.avg_logprob is not None:
|
||||
import math
|
||||
confidence = min(1.0, max(0.0, math.exp(segment.avg_logprob)))
|
||||
total_confidence.append(confidence)
|
||||
|
||||
transcript = " ".join(all_text).strip()
|
||||
avg_confidence = sum(total_confidence) / len(total_confidence) if total_confidence else None
|
||||
duration = info.duration if hasattr(info, "duration") else None
|
||||
|
||||
logger.info(f"Transcription complete: {len(transcript)} chars, lang: {info.language}")
|
||||
|
||||
return TranscribeResponse(
|
||||
success=True,
|
||||
transcript=transcript,
|
||||
language=info.language,
|
||||
confidence=round(avg_confidence, 4) if avg_confidence else None,
|
||||
duration_seconds=round(duration, 2) if duration else None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Transcription failed: {e}", exc_info=True)
|
||||
return TranscribeResponse(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
finally:
|
||||
# Always cleanup temp file
|
||||
if "tmp_path" in locals():
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
6
docker/transcription-worker/requirements.txt
Normal file
6
docker/transcription-worker/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn==0.30.6
|
||||
faster-whisper==1.0.3
|
||||
python-multipart==0.0.9
|
||||
pydantic==2.8.2
|
||||
requests==2.32.3
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user