Compare commits
9 Commits
feature/lo
...
f624150bd8
| Author | SHA1 | Date | |
|---|---|---|---|
| f624150bd8 | |||
| 3229995dec | |||
| 4536ab77ee | |||
| f535530537 | |||
| b0417000e4 | |||
| 59e4ae60da | |||
| 295a408965 | |||
|
|
fc5a4a9b0b | ||
|
|
eb4d950905 |
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||
45
app/Enums/ChannelCategory.php
Normal file
45
app/Enums/ChannelCategory.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
13
dockerized/.dockerignore
Normal 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
48
dockerized/.env.example
Normal 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
3
dockerized/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
runtime/
|
||||
|
||||
206
dockerized/README.md
Normal file
206
dockerized/README.md
Normal 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
70
dockerized/SKILL.md
Normal 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.
|
||||
188
dockerized/docker-compose.yml
Normal file
188
dockerized/docker-compose.yml
Normal 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:
|
||||
75
dockerized/docker/app/Dockerfile
Normal file
75
dockerized/docker/app/Dockerfile
Normal 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"]
|
||||
43
dockerized/docker/app/apache/nyone.conf.template
Normal file
43
dockerized/docker/app/apache/nyone.conf.template
Normal 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>
|
||||
|
||||
134
dockerized/docker/app/entrypoint.sh
Normal file
134
dockerized/docker/app/entrypoint.sh
Normal 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
|
||||
17
dockerized/docker/app/php/production.ini
Normal file
17
dockerized/docker/app/php/production.ini
Normal 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
|
||||
|
||||
17
dockerized/docker/mediamtx/Dockerfile
Normal file
17
dockerized/docker/mediamtx/Dockerfile
Normal 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"]
|
||||
|
||||
34
dockerized/docker/mediamtx/entrypoint.sh
Normal file
34
dockerized/docker/mediamtx/entrypoint.sh
Normal 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."
|
||||
35
dockerized/docker/mediamtx/mediamtx.yml.template
Normal file
35
dockerized/docker/mediamtx/mediamtx.yml.template
Normal 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
|
||||
|
||||
184
dockerized/scripts/deploy.sh
Normal file
184
dockerized/scripts/deploy.sh
Normal 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
|
||||
@@ -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": "ساحة البث هادئة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "فضای زنده آرام است",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 237 KiB |
14
resources/css/fonts/IRANSansWeb/stylesheet.css
Normal file
14
resources/css/fonts/IRANSansWeb/stylesheet.css
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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.'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user