Compare commits

..

9 Commits

Author SHA1 Message Date
f624150bd8 add md files 2026-05-19 15:12:34 +03:30
3229995dec Update the Docker defaults 2026-05-19 15:03:35 +03:30
4536ab77ee implement the Dockerized deployment bundle 2026-05-19 15:00:21 +03:30
f535530537 IRANSans is now referenced with relative URLs 2026-05-18 22:10:24 +03:30
b0417000e4 Removed the Welcome page header nav links 2026-05-18 22:02:12 +03:30
59e4ae60da Changed channel categories from DB-backed Category records to a backed PHP enum 2026-05-18 06:55:03 +03:30
295a408965 Fixed the failing test assertions 2026-05-18 05:55:34 +03:30
Meghdad
fc5a4a9b0b Merge pull request #1 from MeghdadFadaee/feature/localization
Feature/localization
2026-05-18 05:48:43 +03:30
Meghdad
eb4d950905 Create LICENSE 2026-05-17 04:54:27 +03:30
44 changed files with 1334 additions and 190 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Meghdad
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Enums;
enum ChannelCategory: string
{
case Gaming = 'gaming';
case Music = 'music';
case TalkShows = 'talk-shows';
case Education = 'education';
case Creative = 'creative';
public function label(): string
{
return match ($this) {
self::Gaming => __('Gaming'),
self::Music => __('Music'),
self::TalkShows => __('Talk Shows'),
self::Education => __('Education'),
self::Creative => __('Creative'),
};
}
/**
* @return array{value: string, label: string}
*/
public function toOption(): array
{
return [
'value' => $this->value,
'label' => $this->label(),
];
}
/**
* @return list<array{value: string, label: string}>
*/
public static function options(): array
{
return array_map(
fn (self $category): array => $category->toOption(),
self::cases(),
);
}
}

View File

@@ -22,7 +22,7 @@ class ChannelController extends Controller
): Response {
abort_if($channel->isSuspended(), 404);
$channel->load(['category', 'liveBroadcast', 'user']);
$channel->load(['liveBroadcast', 'user']);
$channel->loadCount('follows');
$currentBroadcast = $channel->liveBroadcast;
@@ -39,11 +39,7 @@ class ChannelController extends Controller
'is_live' => $channel->is_live,
'viewer_count' => $channel->is_live ? $viewerCounts->current($channel) : 0,
'followers_count' => $channel->follows_count,
'category' => $channel->category ? [
'id' => $channel->category->id,
'name' => $channel->category->name,
'slug' => $channel->category->slug,
] : null,
'category' => $channel->category?->toOption(),
'owner' => [
'id' => $channel->user->id,
'name' => $channel->user->name,

View File

@@ -2,8 +2,8 @@
namespace App\Http\Controllers;
use App\Enums\ChannelCategory;
use App\Models\Broadcast;
use App\Models\Category;
use App\Models\Channel;
use App\Services\Streaming\StreamKeyManager;
use App\Services\Streaming\ViewerCountStore;
@@ -21,7 +21,7 @@ class CreatorDashboardController extends Controller
{
$channel = $request->user()
->channel()
->with(['category', 'liveBroadcast', 'streamKey'])
->with(['liveBroadcast', 'streamKey'])
->first();
return Inertia::render('dashboard', [
@@ -34,14 +34,7 @@ class CreatorDashboardController extends Controller
? $streamKeys->tokenizedPath($channel, (string) session('plain_stream_key'))
: null,
],
'categories' => Category::query()
->orderBy('name')
->get(['id', 'name', 'slug'])
->map(fn (Category $category) => [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
]),
'categories' => ChannelCategory::options(),
'recentBroadcasts' => $channel
? $channel->broadcasts()->with('vod')->latest()->limit(10)->get()->map(fn (Broadcast $broadcast) => [
'id' => $broadcast->id,
@@ -65,7 +58,7 @@ class CreatorDashboardController extends Controller
'display_name' => ['required', 'string', 'max:80'],
'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', 'unique:channels,slug'],
'description' => ['nullable', 'string', 'max:1000'],
'category_id' => ['nullable', 'exists:categories,id'],
'category' => ['nullable', Rule::enum(ChannelCategory::class)],
]);
$channel = $request->user()->channel()->create([
@@ -92,7 +85,7 @@ class CreatorDashboardController extends Controller
'display_name' => ['required', 'string', 'max:80'],
'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', Rule::unique('channels', 'slug')->ignore($channel)],
'description' => ['nullable', 'string', 'max:1000'],
'category_id' => ['nullable', 'exists:categories,id'],
'category' => ['nullable', Rule::enum(ChannelCategory::class)],
'thumbnail' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
]);
@@ -187,7 +180,7 @@ class CreatorDashboardController extends Controller
'is_live' => $channel->is_live,
'viewer_count' => $channel->viewer_count,
'stream_key_last_used_at' => $channel->streamKey?->last_used_at?->toIso8601String(),
'category_id' => $channel->category_id,
'category' => $channel->category?->value,
'live_broadcast' => $channel->liveBroadcast ? [
'id' => $channel->liveBroadcast->id,
'title' => $channel->liveBroadcast->title,

View File

@@ -2,7 +2,7 @@
namespace App\Http\Controllers;
use App\Models\Category;
use App\Enums\ChannelCategory;
use App\Models\Channel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@@ -17,7 +17,7 @@ class HomeController extends Controller
$liveChannels = Channel::query()
->where('is_live', true)
->whereNull('suspended_at')
->with(['category', 'liveBroadcast', 'user'])
->with(['liveBroadcast', 'user'])
->withCount('follows')
->orderByDesc('viewer_count')
->latest()
@@ -28,14 +28,7 @@ class HomeController extends Controller
return Inertia::render('welcome', [
'canRegister' => Features::enabled(Features::registration()),
'liveChannels' => $liveChannels,
'categories' => Category::query()
->orderBy('name')
->get(['id', 'name', 'slug'])
->map(fn (Category $category) => [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
]),
'categories' => ChannelCategory::options(),
]);
}
@@ -52,11 +45,7 @@ class HomeController extends Controller
'banner_url' => $this->mediaUrl($channel->banner_path),
'viewer_count' => $channel->viewer_count,
'followers_count' => $channel->follows_count ?? 0,
'category' => $channel->category ? [
'id' => $channel->category->id,
'name' => $channel->category->name,
'slug' => $channel->category->slug,
] : null,
'category' => $channel->category?->toOption(),
'broadcast' => $channel->liveBroadcast ? [
'id' => $channel->liveBroadcast->id,
'title' => $channel->liveBroadcast->title,

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Models;
use Database\Factories\CategoryFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'slug', 'description'])]
class Category extends Model
{
/** @use HasFactory<CategoryFactory> */
use HasFactory;
public function channels(): HasMany
{
return $this->hasMany(Channel::class);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\ChannelCategory;
use Database\Factories\ChannelFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'user_id',
'category_id',
'category',
'live_broadcast_id',
'slug',
'display_name',
@@ -32,6 +33,7 @@ class Channel extends Model
protected function casts(): array
{
return [
'category' => ChannelCategory::class,
'is_live' => 'boolean',
'viewer_count' => 'integer',
'suspended_at' => 'datetime',
@@ -48,11 +50,6 @@ class Channel extends Model
return $this->belongsTo(User::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function streamKey(): HasOne
{
return $this->hasOne(StreamKey::class);

View File

@@ -1,20 +0,0 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class CategoryFactory extends Factory
{
public function definition(): array
{
$name = fake()->unique()->words(2, true);
return [
'name' => Str::headline($name),
'slug' => Str::slug($name),
'description' => fake()->optional()->sentence(),
];
}
}

View File

@@ -2,7 +2,7 @@
namespace Database\Factories;
use App\Models\Category;
use App\Enums\ChannelCategory;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
@@ -15,7 +15,7 @@ class ChannelFactory extends Factory
return [
'user_id' => User::factory(),
'category_id' => Category::factory(),
'category' => fake()->randomElement(ChannelCategory::cases())->value,
'slug' => Str::slug($name),
'display_name' => Str::headline($name),
'description' => fake()->sentence(),

View File

@@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('channels', function (Blueprint $table) {
$table->string('category')->nullable()->after('user_id')->index();
});
$allowedCategories = ['gaming', 'music', 'talk-shows', 'education', 'creative'];
DB::table('categories')
->select(['id', 'slug'])
->orderBy('id')
->get()
->each(function (object $category) use ($allowedCategories): void {
if (! in_array($category->slug, $allowedCategories, true)) {
return;
}
DB::table('channels')
->where('category_id', $category->id)
->update(['category' => $category->slug]);
});
Schema::table('channels', function (Blueprint $table) {
$table->dropConstrainedForeignId('category_id');
});
Schema::dropIfExists('categories');
}
public function down(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->timestamps();
});
DB::table('categories')->insert([
['name' => 'Gaming', 'slug' => 'gaming', 'created_at' => now(), 'updated_at' => now()],
['name' => 'Music', 'slug' => 'music', 'created_at' => now(), 'updated_at' => now()],
['name' => 'Talk Shows', 'slug' => 'talk-shows', 'created_at' => now(), 'updated_at' => now()],
['name' => 'Education', 'slug' => 'education', 'created_at' => now(), 'updated_at' => now()],
['name' => 'Creative', 'slug' => 'creative', 'created_at' => now(), 'updated_at' => now()],
]);
Schema::table('channels', function (Blueprint $table) {
$table->foreignId('category_id')->nullable()->after('user_id')->constrained()->nullOnDelete();
});
DB::table('categories')
->select(['id', 'slug'])
->orderBy('id')
->get()
->each(function (object $category): void {
DB::table('channels')
->where('category', $category->slug)
->update(['category_id' => $category->id]);
});
Schema::table('channels', function (Blueprint $table) {
$table->dropIndex(['category']);
$table->dropColumn('category');
});
}
};

View File

@@ -2,7 +2,7 @@
namespace Database\Seeders;
use App\Models\Category;
use App\Enums\ChannelCategory;
use App\Models\PlatformSetting;
use App\Models\User;
use App\Services\Streaming\StreamKeyManager;
@@ -15,17 +15,6 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
collect([
['name' => 'Gaming', 'slug' => 'gaming'],
['name' => 'Music', 'slug' => 'music'],
['name' => 'Talk Shows', 'slug' => 'talk-shows'],
['name' => 'Education', 'slug' => 'education'],
['name' => 'Creative', 'slug' => 'creative'],
])->each(fn (array $category) => Category::query()->firstOrCreate(
['slug' => $category['slug']],
['name' => $category['name']],
));
PlatformSetting::current();
$admin = User::query()->firstOrNew(['email' => 'admin@nyone.net']);
@@ -47,7 +36,7 @@ class DatabaseSeeder extends Seeder
])->save();
$channel = $official->channel()->updateOrCreate([], [
'category_id' => Category::query()->where('slug', 'creative')->value('id'),
'category' => ChannelCategory::Creative,
'slug' => 'nyone',
'display_name' => 'Nyone Official',
'description' => 'Official updates and live sessions from Nyone.',

13
dockerized/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.env
runtime/source/.env
runtime/source/.git
runtime/source/node_modules
runtime/source/vendor
runtime/source/public/build
runtime/source/storage/app/private/recordings
runtime/source/storage/framework/cache
runtime/source/storage/framework/sessions
runtime/source/storage/framework/testing
runtime/source/storage/framework/views
runtime/source/storage/logs

48
dockerized/.env.example Normal file
View File

@@ -0,0 +1,48 @@
COMPOSE_PROJECT_NAME=nyone
# Git source cloned by scripts/deploy.sh into runtime/source before Docker builds.
GIT_REPOSITORY_URL=https://github.com/your-org/nyone.git
GIT_REF=main
# Public hostnames. TLS should terminate at your external proxy/CDN/load balancer.
PUBLIC_SCHEME=https
APP_DOMAIN=nyone.example.com
HLS_DOMAIN=nyone-hls.example.com
APP_HTTP_PORT=80
RTMP_PUBLIC_PORT=1935
APP_NAME=Nyone
APP_ENV=production
APP_DEBUG=false
APP_KEY=
APP_PREVIOUS_KEYS=
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
LOG_LEVEL=info
MAIL_MAILER=log
MAIL_FROM_ADDRESS=hello@example.com
MAIL_FROM_NAME=Nyone
POSTGRES_DB=nyone
POSTGRES_USER=nyone
POSTGRES_PASSWORD=
MEDIAMTX_SHARED_SECRET=
STREAMING_VIEWER_SYNC_INTERVAL=2
STREAMING_VIEWER_COUNT_TTL=10
STREAMING_VIEWER_IDENTITY_COOKIE=nyone_visitor_id
STREAMING_VIEWER_IDENTITY_COOKIE_LIFETIME_DAYS=365
STREAMING_VIEWER_IDENTITY_TTL=86400
STREAMING_VIEWER_STATS_POLL_INTERVAL=30000
STREAMING_VOD_RETENTION_DAYS=30
# Optional deployment behavior.
SEED_DATABASE=false
APP_OPTIMIZE_ON_BOOT=true
QUEUE_NAMES=default
QUEUE_SLEEP=3
QUEUE_TRIES=3
QUEUE_MAX_TIME=3600
QUEUE_TIMEOUT=90

3
dockerized/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
runtime/

206
dockerized/README.md Normal file
View File

@@ -0,0 +1,206 @@
# Nyone Dockerized Deployment
This folder is a self-contained deployment bundle. A server only needs the `dockerized/` directory; the application repository is cloned later into `runtime/source` by `scripts/deploy.sh`.
## What It Runs
- Laravel app served by Apache.
- PostgreSQL database.
- Redis cache for Laravel cache and streaming viewer counts.
- MediaMTX for RTMP ingest, HLS playback, hooks, recordings, and the private API.
- Laravel queue worker.
- Laravel scheduler worker.
- Long-running `streaming:sync-viewer-counts` worker.
Default ports use the services' normal defaults:
- HTTP app/HLS proxy: `80`
- RTMP ingest: `1935`
- MediaMTX HLS inside Docker: `8888`
- MediaMTX API inside Docker: `9997`
- PostgreSQL inside Docker: `5432`
- Redis inside Docker: `6379`
Only HTTP and RTMP are published by default. HLS is served through Apache on `HLS_DOMAIN`, not directly from MediaMTX.
## Server Requirements
- Docker with Compose v2.
- Git.
- OpenSSL.
- Network access from the server to the Git repository.
- DNS for both `APP_DOMAIN` and `HLS_DOMAIN`.
- External TLS termination through a reverse proxy, load balancer, or CDN.
If a host-level reverse proxy already uses port `80`, set `APP_HTTP_PORT` to another local port and forward both hostnames to that port.
## First Deployment
```bash
cd dockerized
cp .env.example .env
nano .env
chmod +x scripts/deploy.sh
./scripts/deploy.sh
```
At minimum, change these values in `.env`:
```env
GIT_REPOSITORY_URL=https://github.com/your-org/nyone.git
GIT_REF=main
APP_DOMAIN=nyone.example.com
HLS_DOMAIN=nyone-hls.example.com
PUBLIC_SCHEME=https
```
Leave `APP_KEY`, `POSTGRES_PASSWORD`, and `MEDIAMTX_SHARED_SECRET` empty on first deploy if you want `scripts/deploy.sh` to generate them.
The deploy script will:
1. Create `.env` from `.env.example` if needed.
2. Generate missing secrets.
3. Clone or update the app into `runtime/source`.
4. Validate the Compose config.
5. Build Docker images.
6. Start PostgreSQL and Redis.
7. Run Laravel migrations.
8. Start Apache, MediaMTX, queue, scheduler, and viewer-sync services.
## Runtime Configuration
This bundle does not use the cloned repository's `.env` file for production settings. Docker Compose passes configuration from `dockerized/.env` into the containers.
Important values:
- `APP_DOMAIN`: Laravel app hostname.
- `HLS_DOMAIN`: HLS playback hostname. Must differ from `APP_DOMAIN`.
- `APP_HTTP_PORT`: host port published by Apache, default `80`.
- `RTMP_PUBLIC_PORT`: host RTMP port, default `1935`.
- `PUBLIC_SCHEME`: usually `https` behind external TLS.
- `SEED_DATABASE`: defaults to `false`; set to `true` only if you intentionally want the app seeder to run.
- `QUEUE_NAMES`: queue list for the Laravel database queue worker, default `default`.
The MediaMTX config is Docker-owned at `docker/mediamtx/mediamtx.yml.template`; it does not mount or depend on `deploy/mediamtx.yml` from the cloned repository.
## Operations
Run commands from the `dockerized/` directory.
```bash
docker compose --env-file .env -f docker-compose.yml ps
docker compose --env-file .env -f docker-compose.yml logs -f app
docker compose --env-file .env -f docker-compose.yml logs -f mediamtx
docker compose --env-file .env -f docker-compose.yml exec app php artisan migrate:status
docker compose --env-file .env -f docker-compose.yml exec app php artisan streaming:sync-viewer-counts --once -v
```
Redeploy after new Git changes:
```bash
./scripts/deploy.sh
```
Stop the stack:
```bash
docker compose --env-file .env -f docker-compose.yml down
```
Do not remove volumes unless you intend to delete database, Redis, and stored app media data.
## External Proxy Notes
Forward both `APP_DOMAIN` and `HLS_DOMAIN` to the Apache container's published HTTP port. Preserve the `Host` header and set `X-Forwarded-Proto: https` when TLS is terminated outside Docker.
Expose TCP `1935` publicly for OBS/RTMP ingest. Do not expose MediaMTX API port `9997` publicly.
## Nginx TLS Proxy Example
Use this pattern when Nginx runs on the host and handles public HTTP/HTTPS. In that setup, let Nginx own ports `80` and `443`, and bind the Docker Apache service to a private local port:
```env
APP_HTTP_PORT=127.0.0.1:8080
PUBLIC_SCHEME=https
APP_DOMAIN=app.example.com
HLS_DOMAIN=hls.example.com
RTMP_PUBLIC_PORT=1935
```
Run `./scripts/deploy.sh` after changing `.env`, then point Nginx at `http://127.0.0.1:8080`. Replace the example domains and certificate paths before enabling the config.
```nginx
# /etc/nginx/sites-available/nyone-dockerized.conf
server {
listen 80;
listen [::]:80;
server_name app.example.com hls.example.com;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
client_max_body_size 100M;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
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-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 120s;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name hls.example.com;
ssl_certificate /etc/letsencrypt/live/hls.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hls.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
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-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-Proto https;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
}
}
```
Enable and validate on the server:
```bash
sudo ln -s /etc/nginx/sites-available/nyone-dockerized.conf /etc/nginx/sites-enabled/nyone-dockerized.conf
sudo nginx -t
sudo systemctl reload nginx
```
Keep RTMP separate from this HTTP proxy. Open TCP `1935` to the Docker host so OBS can publish directly to MediaMTX.

70
dockerized/SKILL.md Normal file
View File

@@ -0,0 +1,70 @@
---
name: dockerized-deployment
description: Work on the Nyone Dockerized deployment bundle under dockerized/. Use when modifying Docker Compose, Apache, MediaMTX, PostgreSQL, Redis, Laravel queue/scheduler/viewer-sync services, deploy.sh, deployment README files, or runtime environment defaults for this self-contained Docker deployment.
---
# Nyone Dockerized Deployment
## Core Rules
- Keep deployment changes inside `dockerized/` unless the user explicitly requests application code changes.
- Treat `dockerized/.env` and `dockerized/runtime/` as server-local generated state; do not commit or depend on them.
- Do not make this bundle depend on the cloned app repository's `.env` or `deploy/mediamtx.yml`.
- Keep MediaMTX config Docker-owned at `docker/mediamtx/mediamtx.yml.template`.
- Keep `APP_DOMAIN` and `HLS_DOMAIN` separate because Apache uses separate virtual hosts.
- Do not run `scripts/deploy.sh`, `docker compose up`, or image builds unless the user confirms this is a deployment server.
## Architecture
- `scripts/deploy.sh` is the entrypoint for server deployment.
- `docker-compose.yml` defines `app`, `db`, `redis`, `mediamtx`, `queue`, `scheduler`, `viewer-sync`, and one-shot `migrate`.
- `docker/app/Dockerfile` builds the Laravel Apache image from `runtime/source`.
- `docker/app/entrypoint.sh` selects behavior by `CONTAINER_ROLE`.
- `docker/app/apache/nyone.conf.template` serves Laravel on `APP_DOMAIN` and proxies HLS for `HLS_DOMAIN`.
- `docker/mediamtx/entrypoint.sh` renders the MediaMTX template at container start.
## Port Defaults
- Apache published HTTP: `80`.
- MediaMTX RTMP: `1935`.
- MediaMTX HLS internal: `8888`.
- MediaMTX API internal: `9997`.
- PostgreSQL internal: `5432`.
- Redis internal: `6379`.
Only publish HTTP and RTMP by default. Keep MediaMTX HLS behind Apache and keep the API private on the Docker network.
## Editing Guidance
- When adding an environment option, update `.env.example`, `docker-compose.yml`, and any entrypoint/template that consumes it.
- When changing MediaMTX ports, update Compose environment, published ports, entrypoint defaults, and the template together.
- When changing Laravel runtime behavior, update the shared `x-app-environment` block unless the value belongs to one role only.
- When changing queue behavior, prefer env-driven options on the `queue` service and `docker/app/entrypoint.sh`.
- Keep deploy script behavior idempotent: repeat runs should update the Git checkout, rebuild, migrate, and restart services without deleting volumes.
- Keep generated secrets in `.env`; do not bake secrets into images or templates.
## Nginx TLS Proxy Guidance
- Document Nginx as a host-level TLS reverse proxy in `README.md`; do not add Nginx as another Compose service unless the user asks.
- Keep Docker Apache as the upstream for both app and HLS hostnames. Nginx should proxy both domains to the published Apache port and preserve the `Host` header so Apache can select the correct virtual host.
- Recommend `APP_HTTP_PORT=127.0.0.1:8080` when Nginx owns host ports `80` and `443`.
- Include `X-Forwarded-Proto https`, `X-Forwarded-Port 443`, `X-Forwarded-Host`, `X-Real-IP`, and `X-Forwarded-For` in proxy examples.
- Keep HLS proxy examples buffering off and cache headers set to no-store.
- Keep RTMP outside the HTTP proxy guidance unless explicitly using Nginx stream proxying; the default is direct TCP `1935` to MediaMTX through Docker.
## Static Verification
Use static checks when not on a deployment server:
```bash
git diff --check
rg "1993[5]|1998[8]|80[8]0|25[2]5|nyone[.]net|nyone-hls[.]net|change-this-secre[t]" dockerized -n
```
If Docker is available and the user allows non-deploy validation, validate only the Compose config:
```bash
docker compose --env-file dockerized/.env.example -f dockerized/docker-compose.yml config
```
Do not start containers during local verification unless the user explicitly asks for runtime testing.

View File

@@ -0,0 +1,188 @@
name: ${COMPOSE_PROJECT_NAME:-nyone-service}
x-app-environment: &app-environment
APP_NAME: ${APP_NAME:-Nyone}
APP_ENV: ${APP_ENV:-production}
APP_DEBUG: ${APP_DEBUG:-false}
APP_KEY: ${APP_KEY:-}
APP_PREVIOUS_KEYS: ${APP_PREVIOUS_KEYS:-}
APP_URL: ${PUBLIC_SCHEME:-https}://${APP_DOMAIN:-nyone.example.com}
APP_LOCALE: ${APP_LOCALE:-en}
APP_FALLBACK_LOCALE: ${APP_FALLBACK_LOCALE:-en}
APP_FAKER_LOCALE: ${APP_FAKER_LOCALE:-en_US}
APP_MAINTENANCE_DRIVER: file
BCRYPT_ROUNDS: 12
LOG_CHANNEL: stack
LOG_STACK: stderr
LOG_DEPRECATIONS_CHANNEL: "null"
LOG_LEVEL: ${LOG_LEVEL:-info}
DB_CONNECTION: pgsql
DB_HOST: db
DB_PORT: 5432
DB_DATABASE: ${POSTGRES_DB:-nyone}
DB_USERNAME: ${POSTGRES_USER:-nyone}
DB_PASSWORD: ${POSTGRES_PASSWORD:-}
SESSION_DRIVER: database
SESSION_LIFETIME: 120
SESSION_ENCRYPT: "false"
SESSION_PATH: /
SESSION_DOMAIN: ${SESSION_DOMAIN:-}
SESSION_SECURE_COOKIE: ${SESSION_SECURE_COOKIE:-true}
BROADCAST_CONNECTION: log
FILESYSTEM_DISK: local
QUEUE_CONNECTION: database
CACHE_STORE: redis
REDIS_CLIENT: phpredis
REDIS_HOST: redis
REDIS_PORT: 6379
MAIL_MAILER: ${MAIL_MAILER:-log}
MAIL_SCHEME: ${MAIL_SCHEME:-}
MAIL_HOST: ${MAIL_HOST:-127.0.0.1}
MAIL_PORT: ${MAIL_PORT:-25}
MAIL_USERNAME: ${MAIL_USERNAME:-}
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS:-hello@example.com}
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Nyone}
VITE_APP_NAME: ${APP_NAME:-Nyone}
STREAMING_RTMP_INGEST_URL: rtmp://${APP_DOMAIN:-nyone.example.com}:${RTMP_PUBLIC_PORT:-1935}
STREAMING_HLS_PUBLIC_URL: ${PUBLIC_SCHEME:-https}://${HLS_DOMAIN:-nyone-hls.example.com}
STREAMING_MEDIAMTX_API_URL: http://mediamtx:9997
STREAMING_VIEWER_COUNT_CACHE_STORE: redis
STREAMING_VIEWER_COUNT_TTL: ${STREAMING_VIEWER_COUNT_TTL:-10}
STREAMING_VIEWER_SYNC_INTERVAL: ${STREAMING_VIEWER_SYNC_INTERVAL:-2}
STREAMING_VIEWER_IDENTITY_COOKIE: ${STREAMING_VIEWER_IDENTITY_COOKIE:-nyone_visitor_id}
STREAMING_VIEWER_IDENTITY_COOKIE_LIFETIME_DAYS: ${STREAMING_VIEWER_IDENTITY_COOKIE_LIFETIME_DAYS:-365}
STREAMING_VIEWER_IDENTITY_TTL: ${STREAMING_VIEWER_IDENTITY_TTL:-86400}
STREAMING_VIEWER_STATS_POLL_INTERVAL: ${STREAMING_VIEWER_STATS_POLL_INTERVAL:-30000}
STREAMING_VOD_RETENTION_DAYS: ${STREAMING_VOD_RETENTION_DAYS:-30}
STREAMING_RECORDINGS_PATH: /var/www/html/storage/app/private/recordings
MEDIAMTX_SHARED_SECRET: ${MEDIAMTX_SHARED_SECRET:-}
APP_DOMAIN: ${APP_DOMAIN:-nyone.example.com}
HLS_DOMAIN: ${HLS_DOMAIN:-nyone-hls.example.com}
HLS_INTERNAL_PORT: 8888
APP_OPTIMIZE_ON_BOOT: ${APP_OPTIMIZE_ON_BOOT:-true}
SEED_DATABASE: ${SEED_DATABASE:-false}
x-app-service: &app-service
image: ${APP_IMAGE:-nyone-app:latest}
build:
context: .
dockerfile: docker/app/Dockerfile
restart: unless-stopped
environment:
<<: *app-environment
volumes:
- app-storage:/var/www/html/storage
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
services:
app:
<<: *app-service
environment:
<<: *app-environment
CONTAINER_ROLE: app
ports:
- "${APP_HTTP_PORT:-80}:80"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/up >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
migrate:
<<: *app-service
restart: "no"
environment:
<<: *app-environment
CONTAINER_ROLE: migrate
profiles:
- tools
queue:
<<: *app-service
environment:
<<: *app-environment
CONTAINER_ROLE: queue
QUEUE_NAMES: ${QUEUE_NAMES:-default}
QUEUE_SLEEP: ${QUEUE_SLEEP:-3}
QUEUE_TRIES: ${QUEUE_TRIES:-3}
QUEUE_MAX_TIME: ${QUEUE_MAX_TIME:-3600}
QUEUE_TIMEOUT: ${QUEUE_TIMEOUT:-90}
scheduler:
<<: *app-service
environment:
<<: *app-environment
CONTAINER_ROLE: scheduler
viewer-sync:
<<: *app-service
environment:
<<: *app-environment
CONTAINER_ROLE: viewer-sync
MEDIAMTX_HOST: mediamtx
MEDIAMTX_API_PORT: 9997
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
mediamtx:
condition: service_started
mediamtx:
image: ${MEDIAMTX_IMAGE:-nyone-mediamtx:latest}
build:
context: .
dockerfile: docker/mediamtx/Dockerfile
restart: unless-stopped
environment:
APP_URL: ${PUBLIC_SCHEME:-https}://${APP_DOMAIN:-nyone.example.com}
HLS_PUBLIC_URL: ${PUBLIC_SCHEME:-https}://${HLS_DOMAIN:-nyone-hls.example.com}
MEDIAMTX_SHARED_SECRET: ${MEDIAMTX_SHARED_SECRET:-}
RTMP_INTERNAL_PORT: 1935
HLS_INTERNAL_PORT: 8888
ports:
- "${RTMP_PUBLIC_PORT:-1935}:1935"
volumes:
- app-storage:/var/www/html/storage
depends_on:
app:
condition: service_started
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-nyone}
POSTGRES_USER: ${POSTGRES_USER:-nyone}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
volumes:
app-storage:
postgres-data:
redis-data:

View File

@@ -0,0 +1,75 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS assets
WORKDIR /build
COPY runtime/source/package.json runtime/source/package-lock.json ./
RUN npm ci
COPY runtime/source/ ./
RUN npm run build
FROM php:8.4-apache-bookworm AS app
ENV APACHE_DOCUMENT_ROOT=/var/www/html/public \
COMPOSER_ALLOW_SUPERUSER=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
gettext-base \
git \
gosu \
libicu-dev \
libcurl4-openssl-dev \
libonig-dev \
libpq-dev \
libzip-dev \
netcat-openbsd \
unzip \
&& docker-php-ext-install -j"$(nproc)" \
bcmath \
curl \
intl \
mbstring \
opcache \
pcntl \
pdo_pgsql \
pgsql \
zip \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& a2enmod headers proxy proxy_http rewrite setenvif \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
COPY runtime/source/composer.json runtime/source/composer.lock ./
RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-scripts
COPY runtime/source/ ./
COPY --from=assets /build/public/build ./public/build
COPY docker/app/apache/nyone.conf.template /etc/apache2/sites-available/nyone.conf.template
COPY docker/app/php/production.ini /usr/local/etc/php/conf.d/production.ini
COPY docker/app/entrypoint.sh /usr/local/bin/nyone-entrypoint
RUN composer dump-autoload --optimize --no-dev \
&& php artisan package:discover --ansi \
&& mkdir -p \
storage/app/private/recordings \
storage/app/public \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache \
&& chown -R www-data:www-data storage bootstrap/cache \
&& chmod +x /usr/local/bin/nyone-entrypoint
ENTRYPOINT ["nyone-entrypoint"]
CMD ["app"]

View File

@@ -0,0 +1,43 @@
ServerName ${APP_DOMAIN}
<VirtualHost *:80>
ServerName ${APP_DOMAIN}
DocumentRoot /var/www/html/public
DirectoryIndex index.php
SetEnvIf X-Forwarded-Proto "^https$" HTTPS=on
SetEnvIf X-Forwarded-Ssl "^on$" HTTPS=on
<Directory /var/www/html/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
<FilesMatch "\.php$">
SetHandler application/x-httpd-php
</FilesMatch>
ErrorLog /proc/self/fd/2
CustomLog /proc/self/fd/1 combined
</VirtualHost>
<VirtualHost *:80>
ServerName ${HLS_DOMAIN}
ProxyRequests Off
ProxyPreserveHost On
ProxyPass "/" "http://mediamtx:${HLS_INTERNAL_PORT}/" retry=0
ProxyPassReverse "/" "http://mediamtx:${HLS_INTERNAL_PORT}/"
Header always set Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate"
Header always set Access-Control-Allow-Origin "${APP_URL}"
Header always set Access-Control-Allow-Methods "GET, HEAD, OPTIONS"
Header always set Access-Control-Allow-Headers "Range, Origin, Accept, Content-Type"
ErrorLog /proc/self/fd/2
CustomLog /proc/self/fd/1 combined
</VirtualHost>

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env bash
set -euo pipefail
cd /var/www/html
role="${CONTAINER_ROLE:-${1:-app}}"
fail() {
echo "nyone-entrypoint: $*" >&2
exit 1
}
require_env() {
local key="$1"
local value="${!key:-}"
if [[ -z "$value" || "$value" == "change-me"* || "$value" == "base64:change-me"* ]]; then
fail "$key is required. Run dockerized/scripts/deploy.sh or set it in dockerized/.env."
fi
}
wait_for() {
local host="$1"
local port="$2"
local label="$3"
local attempts="${4:-60}"
for ((i = 1; i <= attempts; i++)); do
if nc -z "$host" "$port" >/dev/null 2>&1; then
return 0
fi
sleep 2
done
fail "Timed out waiting for $label at $host:$port."
}
artisan() {
gosu www-data php artisan "$@"
}
render_apache_config() {
export APP_DOMAIN HLS_DOMAIN HLS_INTERNAL_PORT APP_URL
envsubst '${APP_DOMAIN} ${HLS_DOMAIN} ${HLS_INTERNAL_PORT} ${APP_URL}' \
< /etc/apache2/sites-available/nyone.conf.template \
> /etc/apache2/sites-available/000-default.conf
}
prepare_laravel_paths() {
mkdir -p \
storage/app/private/recordings \
storage/app/public \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache
}
cache_laravel_bootstrap() {
if [[ "${APP_OPTIMIZE_ON_BOOT:-true}" != "true" ]]; then
return 0
fi
artisan config:clear
artisan route:clear
artisan view:clear
artisan event:clear
artisan config:cache
artisan route:cache
artisan view:cache
}
require_env APP_KEY
require_env APP_DOMAIN
require_env HLS_DOMAIN
require_env MEDIAMTX_SHARED_SECRET
require_env DB_DATABASE
require_env DB_USERNAME
require_env DB_PASSWORD
if [[ "${WAIT_FOR_SERVICES:-true}" == "true" ]]; then
wait_for "${DB_HOST:-db}" "${DB_PORT:-5432}" "PostgreSQL"
wait_for "${REDIS_HOST:-redis}" "${REDIS_PORT:-6379}" "Redis"
if [[ "$role" == "viewer-sync" ]]; then
wait_for "${MEDIAMTX_HOST:-mediamtx}" "${MEDIAMTX_API_PORT:-9997}" "MediaMTX API"
fi
fi
prepare_laravel_paths
artisan storage:link --force >/dev/null || true
cache_laravel_bootstrap
case "$role" in
app)
render_apache_config
exec apache2-foreground
;;
migrate)
artisan migrate --force
if [[ "${SEED_DATABASE:-false}" == "true" ]]; then
artisan db:seed --force
fi
;;
queue)
exec gosu www-data php artisan queue:work "${QUEUE_CONNECTION:-database}" \
--queue="${QUEUE_NAMES:-default}" \
--sleep="${QUEUE_SLEEP:-3}" \
--tries="${QUEUE_TRIES:-3}" \
--max-time="${QUEUE_MAX_TIME:-3600}" \
--timeout="${QUEUE_TIMEOUT:-90}"
;;
scheduler)
exec gosu www-data php artisan schedule:work
;;
viewer-sync)
exec gosu www-data php artisan streaming:sync-viewer-counts \
--isolated \
--interval="${STREAMING_VIEWER_SYNC_INTERVAL:-2}"
;;
artisan)
shift || true
exec gosu www-data php artisan "$@"
;;
*)
exec "$@"
;;
esac

View File

@@ -0,0 +1,17 @@
expose_php=Off
memory_limit=512M
upload_max_filesize=100M
post_max_size=100M
max_execution_time=120
max_input_vars=3000
variables_order=EGPCS
opcache.enable=1
opcache.enable_cli=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1
opcache.jit=0

View File

@@ -0,0 +1,17 @@
FROM bluenviron/mediamtx:latest-ffmpeg
USER root
RUN (apk add --no-cache curl gettext >/dev/null 2>&1) \
|| (apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gettext-base \
&& rm -rf /var/lib/apt/lists/*)
COPY docker/mediamtx/entrypoint.sh /usr/local/bin/nyone-mediamtx-entrypoint
COPY docker/mediamtx/mediamtx.yml.template /etc/mediamtx/mediamtx.yml.template
RUN chmod +x /usr/local/bin/nyone-mediamtx-entrypoint \
&& mkdir -p /config /var/www/html/storage/app/private/recordings
ENTRYPOINT ["nyone-mediamtx-entrypoint"]

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env sh
set -eu
fail() {
echo "nyone-mediamtx-entrypoint: $*" >&2
exit 1
}
if [ -z "${MEDIAMTX_SHARED_SECRET:-}" ] || [ "${MEDIAMTX_SHARED_SECRET#change-me}" != "$MEDIAMTX_SHARED_SECRET" ]; then
fail "MEDIAMTX_SHARED_SECRET is required. Run dockerized/scripts/deploy.sh or set it in dockerized/.env."
fi
: "${APP_URL:?APP_URL is required.}"
: "${HLS_PUBLIC_URL:?HLS_PUBLIC_URL is required.}"
: "${RTMP_INTERNAL_PORT:=1935}"
: "${HLS_INTERNAL_PORT:=8888}"
mkdir -p /config /var/www/html/storage/app/private/recordings
export APP_URL HLS_PUBLIC_URL MEDIAMTX_SHARED_SECRET RTMP_INTERNAL_PORT HLS_INTERNAL_PORT
envsubst '${APP_URL} ${HLS_PUBLIC_URL} ${MEDIAMTX_SHARED_SECRET} ${RTMP_INTERNAL_PORT} ${HLS_INTERNAL_PORT}' \
< /etc/mediamtx/mediamtx.yml.template \
> /config/mediamtx.yml
if command -v mediamtx >/dev/null 2>&1; then
exec mediamtx /config/mediamtx.yml
fi
if [ -x /mediamtx ]; then
exec /mediamtx /config/mediamtx.yml
fi
fail "MediaMTX binary was not found in the base image."

View File

@@ -0,0 +1,35 @@
authMethod: http
authHTTPAddress: http://app/internal/mediamtx/auth?secret=${MEDIAMTX_SHARED_SECRET}
authHTTPExclude:
- action: read
- action: playback
- action: api
- action: metrics
- action: pprof
api: yes
apiAddress: :9997
rtmp: yes
rtmpAddress: :${RTMP_INTERNAL_PORT}
hls: yes
hlsAddress: :${HLS_INTERNAL_PORT}
hlsVariant: mpegts
hlsAllowOrigins:
- ${APP_URL}
- ${HLS_PUBLIC_URL}
pathDefaults:
record: yes
recordPath: /var/www/html/storage/app/private/recordings/%path/%Y-%m-%d_%H-%M-%S-%f
runOnReady: curl -fsS -X POST "http://app/internal/mediamtx/ready?secret=${MEDIAMTX_SHARED_SECRET}" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
runOnReadyRestart: no
runOnNotReady: curl -fsS -X POST "http://app/internal/mediamtx/not-ready?secret=${MEDIAMTX_SHARED_SECRET}" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
runOnRecordSegmentComplete: curl -fsS -X POST "http://app/internal/mediamtx/recording-complete?secret=${MEDIAMTX_SHARED_SECRET}" --data-urlencode "path=$MTX_PATH" --data-urlencode "segment_path=$MTX_SEGMENT_PATH" --data-urlencode "duration=$MTX_SEGMENT_DURATION"
paths:
all_others:
source: publisher

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="$ROOT_DIR/.env"
EXAMPLE_ENV_FILE="$ROOT_DIR/.env.example"
SOURCE_DIR="$ROOT_DIR/runtime/source"
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
info() {
printf '[nyone-deploy] %s\n' "$*"
}
fail() {
printf '[nyone-deploy] ERROR: %s\n' "$*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "$1 is required."
}
env_value() {
local key="$1"
if [ ! -f "$ENV_FILE" ]; then
return 0
fi
awk -v key="$key" '
index($0, key "=") == 1 {
value = substr($0, length(key) + 2)
}
END {
print value
}
' "$ENV_FILE"
}
set_env_value() {
local key="$1"
local value="$2"
local escaped
escaped="$(printf '%s' "$value" | sed 's/[\/&]/\\&/g')"
if grep -qE "^${key}=" "$ENV_FILE"; then
sed -i.bak "s/^${key}=.*/${key}=${escaped}/" "$ENV_FILE"
rm -f "$ENV_FILE.bak"
else
printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE"
fi
}
is_missing_secret() {
local value="$1"
[ -z "$value" ] || [[ "$value" == "change-me"* ]] || [[ "$value" == "base64:change-me"* ]]
}
random_secret() {
openssl rand -base64 48 | tr -d '\n' | tr '/+' '_-' | tr -d '='
}
ensure_env_file() {
if [ -f "$ENV_FILE" ]; then
return 0
fi
[ -f "$EXAMPLE_ENV_FILE" ] || fail ".env.example was not found."
cp "$EXAMPLE_ENV_FILE" "$ENV_FILE"
info "Created .env from .env.example."
}
ensure_secret() {
local key="$1"
local value
value="$(env_value "$key")"
if is_missing_secret "$value"; then
set_env_value "$key" "$(random_secret)"
info "Generated $key."
fi
}
require_config_value() {
local key="$1"
local placeholder="${2:-}"
local value
value="$(env_value "$key")"
[ -n "$value" ] || fail "Set $key in $ENV_FILE."
if [ -n "$placeholder" ] && [ "$value" = "$placeholder" ]; then
fail "Replace the placeholder $key in $ENV_FILE."
fi
}
ensure_app_key() {
local value
value="$(env_value APP_KEY)"
if is_missing_secret "$value"; then
set_env_value APP_KEY "base64:$(openssl rand -base64 32 | tr -d '\n')"
info "Generated APP_KEY."
fi
}
clone_or_update_source() {
local repository_url="$1"
local git_ref="$2"
mkdir -p "$(dirname "$SOURCE_DIR")"
if [ -d "$SOURCE_DIR/.git" ]; then
info "Updating existing source checkout."
git -C "$SOURCE_DIR" fetch --prune --tags origin
if git -C "$SOURCE_DIR" rev-parse --verify --quiet "origin/$git_ref" >/dev/null; then
git -C "$SOURCE_DIR" checkout -B "$git_ref" "origin/$git_ref"
else
git -C "$SOURCE_DIR" checkout "$git_ref"
fi
return 0
fi
if [ -e "$SOURCE_DIR" ]; then
fail "$SOURCE_DIR exists but is not a Git checkout. Move it away or remove it before deploying."
fi
info "Cloning application repository."
git clone "$repository_url" "$SOURCE_DIR"
git -C "$SOURCE_DIR" checkout "$git_ref"
}
run_compose() {
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
}
require_command git
require_command docker
require_command openssl
ensure_env_file
ensure_app_key
ensure_secret POSTGRES_PASSWORD
ensure_secret MEDIAMTX_SHARED_SECRET
GIT_REPOSITORY_URL="$(env_value GIT_REPOSITORY_URL)"
GIT_REF="$(env_value GIT_REF)"
require_config_value GIT_REPOSITORY_URL "https://github.com/your-org/nyone.git"
require_config_value APP_DOMAIN "nyone.example.com"
require_config_value HLS_DOMAIN "nyone-hls.example.com"
if [ "$(env_value APP_DOMAIN)" = "$(env_value HLS_DOMAIN)" ]; then
fail "APP_DOMAIN and HLS_DOMAIN must be different because this deployment uses separate Apache virtual hosts."
fi
GIT_REF="${GIT_REF:-main}"
clone_or_update_source "$GIT_REPOSITORY_URL" "$GIT_REF"
info "Validating Compose configuration."
run_compose config >/dev/null
info "Building Docker images."
run_compose build
info "Starting database and Redis."
run_compose up -d db redis
info "Running migrations."
run_compose --profile tools run --rm migrate
info "Starting background workers."
run_compose up -d --remove-orphans app mediamtx queue scheduler viewer-sync
info "Current container status:"
run_compose ps

View File

@@ -82,6 +82,7 @@
"Create an account": "إنشاء حساب",
"Create channel": "إنشاء قناة",
"Create your channel": "أنشئ قناتك",
"Creative": "الإبداع",
"Creator Studio": "استوديو المنشئ",
"Creator access grants stay in the moderation console.": "تبقى أذونات وصول المنشئ في وحدة الإشراف.",
"Creator grants": "أذونات المنشئ",
@@ -101,6 +102,7 @@
"Don't have an account?": "ليس لديك حساب؟",
"Each account can own one public channel in this version.": "يمكن لكل حساب امتلاك قناة عامة واحدة في هذا الإصدار.",
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "يمكن استخدام كل رمز استرداد مرة واحدة للوصول إلى حسابك وسيتم حذفه بعد الاستخدام. إذا احتجت إلى المزيد، فانقر",
"Education": "التعليم",
"Email": "البريد الإلكتروني",
"Email address": "عنوان البريد الإلكتروني",
"Email password reset link": "إرسال رابط إعادة تعيين كلمة المرور",
@@ -123,6 +125,7 @@
"Forgot password": "نسيت كلمة المرور",
"Forgot password?": "هل نسيت كلمة المرور؟",
"Full name": "الاسم الكامل",
"Gaming": "الألعاب",
"Grant": "منح",
"Hide password": "إخفاء كلمة المرور",
"Hide recovery codes": "إخفاء رموز الاسترداد",
@@ -152,6 +155,7 @@
"Message sent to admin.": "تم إرسال الرسالة إلى المسؤول.",
"Moderation console": "وحدة الإشراف",
"More": "المزيد",
"Music": "الموسيقى",
"NEEDS APPROVAL": "يتطلب موافقة",
"NEEDS CHANNEL GRANT": "يتطلب إذن قناة",
"Name": "الاسم",
@@ -268,6 +272,7 @@
"Suspend": "تعليق",
"Suspend channel?": "تعليق القناة؟",
"System": "النظام",
"Talk Shows": "برامج الحوار",
"The channel will be hidden from public browsing and watch pages.": "سيتم إخفاء القناة من التصفح العام وصفحات المشاهدة.",
"The channel will be visible again if the owning account is active.": "ستظهر القناة مرة أخرى إذا كان حساب المالك نشطًا.",
"The live floor is quiet": "ساحة البث هادئة",

View File

@@ -82,6 +82,7 @@
"Create an account": "Create an account",
"Create channel": "Create channel",
"Create your channel": "Create your channel",
"Creative": "Creative",
"Creator Studio": "Creator Studio",
"Creator access grants stay in the moderation console.": "Creator access grants stay in the moderation console.",
"Creator grants": "Creator grants",
@@ -101,6 +102,7 @@
"Don't have an account?": "Don't have an account?",
"Each account can own one public channel in this version.": "Each account can own one public channel in this version.",
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "Each recovery code can be used once to access your account and will be removed after use. If you need more, click",
"Education": "Education",
"Email": "Email",
"Email address": "Email address",
"Email password reset link": "Email password reset link",
@@ -123,6 +125,7 @@
"Forgot password": "Forgot password",
"Forgot password?": "Forgot password?",
"Full name": "Full name",
"Gaming": "Gaming",
"Grant": "Grant",
"Hide password": "Hide password",
"Hide recovery codes": "Hide recovery codes",
@@ -152,6 +155,7 @@
"Message sent to admin.": "Message sent to admin.",
"Moderation console": "Moderation console",
"More": "More",
"Music": "Music",
"NEEDS APPROVAL": "NEEDS APPROVAL",
"NEEDS CHANNEL GRANT": "NEEDS CHANNEL GRANT",
"Name": "Name",
@@ -268,6 +272,7 @@
"Suspend": "Suspend",
"Suspend channel?": "Suspend channel?",
"System": "System",
"Talk Shows": "Talk Shows",
"The channel will be hidden from public browsing and watch pages.": "The channel will be hidden from public browsing and watch pages.",
"The channel will be visible again if the owning account is active.": "The channel will be visible again if the owning account is active.",
"The live floor is quiet": "The live floor is quiet",

View File

@@ -82,6 +82,7 @@
"Create an account": "Crear una cuenta",
"Create channel": "Crear canal",
"Create your channel": "Crea tu canal",
"Creative": "Creatividad",
"Creator Studio": "Estudio de creador",
"Creator access grants stay in the moderation console.": "Los permisos de acceso de creador permanecen en la consola de moderación.",
"Creator grants": "Permisos de creador",
@@ -101,6 +102,7 @@
"Don't have an account?": "¿No tienes una cuenta?",
"Each account can own one public channel in this version.": "Cada cuenta puede tener un canal público en esta versión.",
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "Cada código de recuperación puede usarse una vez para acceder a tu cuenta y se eliminará después de usarlo. Si necesitas más, haz clic",
"Education": "Educación",
"Email": "Correo electrónico",
"Email address": "Dirección de correo electrónico",
"Email password reset link": "Enviar enlace para restablecer contraseña",
@@ -123,6 +125,7 @@
"Forgot password": "Olvidé mi contraseña",
"Forgot password?": "¿Olvidaste tu contraseña?",
"Full name": "Nombre completo",
"Gaming": "Videojuegos",
"Grant": "Conceder",
"Hide password": "Ocultar contraseña",
"Hide recovery codes": "Ocultar códigos de recuperación",
@@ -152,6 +155,7 @@
"Message sent to admin.": "Mensaje enviado al administrador.",
"Moderation console": "Consola de moderación",
"More": "Más",
"Music": "Música",
"NEEDS APPROVAL": "REQUIERE APROBACIÓN",
"NEEDS CHANNEL GRANT": "REQUIERE PERMISO DE CANAL",
"Name": "Nombre",
@@ -268,6 +272,7 @@
"Suspend": "Suspender",
"Suspend channel?": "¿Suspender canal?",
"System": "Sistema",
"Talk Shows": "Programas de conversación",
"The channel will be hidden from public browsing and watch pages.": "El canal se ocultará de la navegación pública y de las páginas de visualización.",
"The channel will be visible again if the owning account is active.": "El canal volverá a estar visible si la cuenta propietaria está activa.",
"The live floor is quiet": "No hay actividad en vivo",

View File

@@ -82,6 +82,7 @@
"Create an account": "ایجاد یک حساب",
"Create channel": "ایجاد کانال",
"Create your channel": "کانال خود را ایجاد کنید",
"Creative": "خلاقیت",
"Creator Studio": "استودیوی سازنده",
"Creator access grants stay in the moderation console.": "مجوزهای دسترسی سازنده در کنسول مدیریت باقی می‌مانند.",
"Creator grants": "مجوزهای سازنده",
@@ -101,6 +102,7 @@
"Don't have an account?": "حساب ندارید؟",
"Each account can own one public channel in this version.": "در این نسخه هر حساب می‌تواند یک کانال عمومی داشته باشد.",
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "هر کد بازیابی فقط یک‌بار برای دسترسی به حساب شما قابل استفاده است و پس از استفاده حذف می‌شود. اگر به کدهای بیشتری نیاز دارید، کلیک کنید",
"Education": "آموزش",
"Email": "ایمیل",
"Email address": "آدرس ایمیل",
"Email password reset link": "ارسال لینک بازنشانی رمز عبور",
@@ -123,6 +125,7 @@
"Forgot password": "فراموشی رمز عبور",
"Forgot password?": "رمز عبور را فراموش کرده‌اید؟",
"Full name": "نام کامل",
"Gaming": "بازی",
"Grant": "اعطا",
"Hide password": "پنهان کردن رمز عبور",
"Hide recovery codes": "پنهان کردن کدهای بازیابی",
@@ -152,6 +155,7 @@
"Message sent to admin.": "پیام برای مدیر ارسال شد.",
"Moderation console": "کنسول مدیریت",
"More": "بیشتر",
"Music": "موسیقی",
"NEEDS APPROVAL": "نیازمند تأیید",
"NEEDS CHANNEL GRANT": "نیازمند مجوز کانال",
"Name": "نام",
@@ -268,6 +272,7 @@
"Suspend": "تعلیق",
"Suspend channel?": "کانال تعلیق شود؟",
"System": "سیستم",
"Talk Shows": "گفت‌وگو",
"The channel will be hidden from public browsing and watch pages.": "کانال از مرور عمومی و صفحات تماشا پنهان خواهد شد.",
"The channel will be visible again if the owning account is active.": "اگر حساب مالک فعال باشد، کانال دوباره قابل مشاهده خواهد شد.",
"The live floor is quiet": "فضای زنده آرام است",

View File

@@ -1,14 +0,0 @@
@font-face {
font-family: 'IRANSansWeb';
src: url('IRANSansWeb.eot');
src: local('IRANSansWeb'),
url('IRANSansWeb.eot?#iefix') format('embedded-opentype'),
url('IRANSansWeb.woff2') format('woff2'),
url('IRANSansWeb.woff') format('woff'),
url('IRANSansWeb.ttf') format('truetype'),
url('IRANSansWeb.svg#IRANSansWeb') format('svg');
font-weight: normal;
font-style: normal;
font-display: swap;
}

View File

@@ -10,9 +10,9 @@
@font-face {
font-family: 'IRANSansWeb';
src:
url('/fonts/IRANSansWeb/IRANSansWeb.woff2') format('woff2'),
url('/fonts/IRANSansWeb/IRANSansWeb.woff') format('woff'),
url('/fonts/IRANSansWeb/IRANSansWeb.ttf') format('truetype');
url('./fonts/IRANSansWeb/IRANSansWeb.woff2') format('woff2'),
url('./fonts/IRANSansWeb/IRANSansWeb.woff') format('woff'),
url('./fonts/IRANSansWeb/IRANSansWeb.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;

View File

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 237 KiB

View File

@@ -0,0 +1,14 @@
@font-face {
font-family: 'IRANSansWeb';
src: url('IRANSansWeb.eot');
src:
local('IRANSansWeb'),
url('IRANSansWeb.eot?#iefix') format('embedded-opentype'),
url('IRANSansWeb.woff2') format('woff2'),
url('IRANSansWeb.woff') format('woff'),
url('IRANSansWeb.ttf') format('truetype'),
url('IRANSansWeb.svg#IRANSansWeb') format('svg');
font-weight: normal;
font-style: normal;
font-display: swap;
}

View File

@@ -150,7 +150,7 @@ export default function ChannelShow({
<LiveState channel={channel} />
{channel.category && (
<span className="rounded-md border px-2 py-1 text-xs">
{channel.category.name}
{channel.category.label}
</span>
)}
</div>

View File

@@ -38,7 +38,7 @@ import {
update as updateChannelRoute,
} from '@/routes/creator/channel';
import { rotate as rotateStreamKeyRoute } from '@/routes/creator/stream-key';
import type { BroadcastSummary, Category } from '@/types';
import type { BroadcastSummary, ChannelCategory } from '@/types';
type CreatorChannel = {
id: number;
@@ -51,7 +51,7 @@ type CreatorChannel = {
is_live: boolean;
viewer_count: number;
stream_key_last_used_at: string | null;
category_id: number | null;
category: string | null;
live_broadcast: BroadcastSummary | null;
};
@@ -63,7 +63,7 @@ type Props = {
ingestServer: string;
tokenizedPath: string | null;
};
categories: Category[];
categories: ChannelCategory[];
recentBroadcasts: BroadcastSummary[];
};
@@ -90,14 +90,14 @@ export default function Dashboard({
display_name: '',
slug: '',
description: '',
category_id: '',
category: '',
});
const channelForm = useForm({
_method: 'PATCH',
display_name: channel?.display_name ?? '',
slug: channel?.slug ?? '',
description: channel?.description ?? '',
category_id: channel?.category_id ? String(channel.category_id) : '',
category: channel?.category ?? '',
thumbnail: null as File | null,
});
const broadcastForm = useForm({
@@ -235,13 +235,16 @@ export default function Dashboard({
placeholder={t('nyone-live')}
/>
</Field>
<Field label="Category">
<Field
label="Category"
error={createForm.errors.category}
>
<CategorySelect
categories={categories}
value={createForm.data.category_id}
value={createForm.data.category}
onChange={(value) =>
createForm.setData(
'category_id',
'category',
value,
)
}
@@ -440,13 +443,16 @@ export default function Dashboard({
}
/>
</Field>
<Field label="Category">
<Field
label="Category"
error={channelForm.errors.category}
>
<CategorySelect
categories={categories}
value={channelForm.data.category_id}
value={channelForm.data.category}
onChange={(value) =>
channelForm.setData(
'category_id',
'category',
value,
)
}
@@ -763,7 +769,7 @@ function CategorySelect({
value,
onChange,
}: {
categories: Category[];
categories: ChannelCategory[];
value: string;
onChange: (value: string) => void;
}) {
@@ -777,8 +783,8 @@ function CategorySelect({
>
<option value="">{t('No category')}</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
<option key={category.value} value={category.value}>
{category.label}
</option>
))}
</select>

View File

@@ -2,7 +2,6 @@ import { Head, Link, usePage } from '@inertiajs/react';
import {
ArrowRight,
Compass,
LifeBuoy,
LayoutDashboard,
Radio,
Search,
@@ -18,13 +17,12 @@ import { useTranslation } from '@/lib/translations';
import { cn } from '@/lib/utils';
import { dashboard, home, login, register } from '@/routes';
import { show as showChannel } from '@/routes/channels';
import { index as supportIndex } from '@/routes/support';
import type { Category, ChannelCard } from '@/types';
import type { ChannelCard, ChannelCategory } from '@/types';
type Props = {
canRegister: boolean;
liveChannels: ChannelCard[];
categories: Category[];
categories: ChannelCategory[];
};
export default function Welcome({
@@ -43,7 +41,7 @@ export default function Welcome({
return liveChannels.filter((channel) => {
const matchesCategory =
category === 'all' || channel.category?.slug === category;
category === 'all' || channel.category?.value === category;
const matchesQuery =
normalizedQuery === '' ||
channel.display_name.toLowerCase().includes(normalizedQuery) ||
@@ -72,48 +70,15 @@ export default function Welcome({
<span>Nyone</span>
</Link>
<nav className="hidden items-center gap-1 text-sm text-muted-foreground md:flex">
<Link
href={home()}
className="rounded-md px-3 py-2 text-foreground"
>
{t('Live')}
</Link>
{auth.user && (
<>
<Link
href={dashboard()}
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
>
{t('Studio')}
</Link>
<Link
href={supportIndex()}
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
>
{t('Contact admin')}
</Link>
</>
)}
</nav>
<div className="ms-auto flex items-center gap-2">
<LanguageSwitcher />
{auth.user ? (
<>
<Button asChild size="sm">
<Link href={dashboard()}>
<LayoutDashboard className="size-4" />
{t('Studio')}
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={supportIndex()}>
<LifeBuoy className="size-4" />
{t('Contact admin')}
</Link>
</Button>
</>
<Button asChild size="sm">
<Link href={dashboard()}>
<LayoutDashboard className="size-4" />
{t('Studio')}
</Link>
</Button>
) : (
<>
<Button asChild variant="ghost" size="sm">
@@ -175,7 +140,7 @@ export default function Welcome({
</span>
<span className="min-w-0 break-words">
{featuredChannel.category
?.name ??
?.label ??
t('Uncategorized')}
</span>
<span className="inline-flex items-center gap-1">
@@ -292,8 +257,11 @@ export default function Welcome({
{t('All categories')}
</option>
{categories.map((item) => (
<option key={item.id} value={item.slug}>
{item.name}
<option
key={item.value}
value={item.value}
>
{item.label}
</option>
))}
</select>
@@ -356,7 +324,7 @@ function StreamCard({ channel }: { channel: ChannelCard }) {
</div>
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span className="truncate">
{channel.category?.name ?? t('Uncategorized')}
{channel.category?.label ?? t('Uncategorized')}
</span>
<span className="shrink-0">
{t(':count viewers', {

View File

@@ -1,7 +1,6 @@
export type Category = {
id: number;
name: string;
slug: string;
export type ChannelCategory = {
value: string;
label: string;
};
export type ChannelCard = {
@@ -15,7 +14,7 @@ export type ChannelCard = {
banner_url: string | null;
viewer_count: number;
followers_count: number;
category: Category | null;
category: ChannelCategory | null;
broadcast: {
id: number;
title: string;
@@ -38,7 +37,7 @@ export type ChannelDetail = {
is_live: boolean;
viewer_count: number;
followers_count: number;
category: Category | null;
category: ChannelCategory | null;
owner: {
id: number;
name: string;

View File

@@ -2,7 +2,7 @@
namespace Tests\Feature;
use App\Models\Category;
use App\Enums\ChannelCategory;
use App\Models\Channel;
use App\Models\PlatformSetting;
use App\Models\StreamKey;
@@ -17,13 +17,12 @@ class CreatorChannelTest extends TestCase
public function test_user_with_creator_access_can_create_one_channel_and_receives_one_time_stream_key(): void
{
$user = User::factory()->create(['can_create_channel' => true]);
$category = Category::factory()->create();
$response = $this->actingAs($user)->post(route('creator.channel.store'), [
'display_name' => 'Nyone Live',
'slug' => 'nyone-live',
'description' => 'Live builds and demos.',
'category_id' => $category->id,
'category' => ChannelCategory::Creative->value,
]);
$response
@@ -34,7 +33,7 @@ class CreatorChannelTest extends TestCase
'user_id' => $user->id,
'slug' => 'nyone-live',
'display_name' => 'Nyone Live',
'category_id' => $category->id,
'category' => ChannelCategory::Creative->value,
]);
$this->assertDatabaseCount(StreamKey::class, 1);

View File

@@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Enums\ChannelCategory;
use App\Models\Broadcast;
use App\Models\Channel;
use App\Models\User;
@@ -64,6 +65,25 @@ class DashboardTest extends TestCase
);
}
public function test_home_page_exposes_translated_channel_category_labels(): void
{
config(['app.locale' => 'es']);
Channel::factory()->create([
'is_live' => true,
'category' => ChannelCategory::Music,
]);
$this->get(route('home'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('welcome')
->where('liveChannels.0.category.value', ChannelCategory::Music->value)
->where('liveChannels.0.category.label', __('Music', [], 'es'))
->where('categories', fn ($categories) => $categories->firstWhere('value', ChannelCategory::Music->value)['label'] === __('Music', [], 'es')),
);
}
public function test_creator_dashboard_exposes_channel_display_media_props(): void
{
$user = User::factory()->create();
@@ -101,7 +121,7 @@ class DashboardTest extends TestCase
'display_name' => $channel->display_name,
'slug' => $channel->slug,
'description' => $channel->description,
'category_id' => $channel->category_id,
'category' => $channel->category?->value,
'thumbnail' => UploadedFile::fake()->image('thumbnail.jpg', 1280, 720),
])
->assertRedirect()
@@ -131,7 +151,7 @@ class DashboardTest extends TestCase
'display_name' => $channel->display_name,
'slug' => $channel->slug,
'description' => $channel->description,
'category_id' => $channel->category_id,
'category' => $channel->category?->value,
'thumbnail' => UploadedFile::fake()->create('thumbnail.txt', 8, 'text/plain'),
])
->assertRedirect(route('dashboard'))

View File

@@ -76,7 +76,7 @@ class SupportConversationTest extends TestCase
'category',
'subject',
'body',
'attachments.0' => 'Unsupported attachment type. Upload media, PDF, text/data, Office, OpenDocument, or archive files.',
'attachments.0' => __('Unsupported attachment type. Upload media, PDF, text/data, Office, OpenDocument, or archive files.'),
]);
}
@@ -93,7 +93,7 @@ class SupportConversationTest extends TestCase
'attachments' => [$attachment],
])
->assertSessionHasErrors([
'attachments.0' => 'Attachments must be 200 MB or smaller.',
'attachments.0' => __('Attachments must be 200 MB or smaller.'),
]);
}