Compare commits
19 Commits
feature/lo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b3bb160b90 | |||
| cb62a0bae6 | |||
| f302d605d2 | |||
| cdb89d3287 | |||
| da19b6a8a2 | |||
| 81dd742120 | |||
| 34a683b614 | |||
| c06e237cd1 | |||
| 0fb7531e63 | |||
| b3b7ebf8d7 | |||
| f624150bd8 | |||
| 3229995dec | |||
| 4536ab77ee | |||
| f535530537 | |||
| b0417000e4 | |||
| 59e4ae60da | |||
| 295a408965 | |||
|
|
fc5a4a9b0b | ||
|
|
eb4d950905 |
@@ -2,7 +2,7 @@ APP_NAME=Nyone
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://nyone.net
|
||||
APP_URL=https://nyone.app
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
@@ -64,7 +64,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
STREAMING_RTMP_INGEST_URL=rtmp://nyone.net:19935
|
||||
STREAMING_RTMP_INGEST_URL=rtmp://nyone.app:19935
|
||||
STREAMING_HLS_PUBLIC_URL=https://nyone-hls.net
|
||||
STREAMING_MEDIAMTX_API_URL=http://127.0.0.1:9997
|
||||
STREAMING_VIEWER_COUNT_CACHE_STORE=redis
|
||||
|
||||
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.
|
||||
@@ -35,8 +35,8 @@ The project config is aligned to these production-style domains:
|
||||
|
||||
```env
|
||||
APP_NAME=Nyone
|
||||
APP_URL=https://nyone.net
|
||||
STREAMING_RTMP_INGEST_URL=rtmp://nyone.net:19935
|
||||
APP_URL=https://nyone.app
|
||||
STREAMING_RTMP_INGEST_URL=rtmp://nyone.app:19935
|
||||
STREAMING_HLS_PUBLIC_URL=https://nyone-hls.net
|
||||
MEDIAMTX_SHARED_SECRET=change-this-secret
|
||||
```
|
||||
|
||||
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);
|
||||
|
||||
@@ -52,7 +52,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'https://nyone.net'),
|
||||
'url' => env('APP_URL', 'https://nyone.app'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -41,7 +41,7 @@ return [
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => rtrim(env('APP_URL', 'https://nyone.net'), '/').'/storage',
|
||||
'url' => rtrim(env('APP_URL', 'https://nyone.app'), '/').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
|
||||
@@ -46,7 +46,7 @@ return [
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'https://nyone.net'), PHP_URL_HOST)),
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'https://nyone.app'), PHP_URL_HOST)),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
|
||||
@@ -12,7 +12,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'rtmp_ingest_url' => env('STREAMING_RTMP_INGEST_URL', 'rtmp://nyone.net:19935'),
|
||||
'rtmp_ingest_url' => env('STREAMING_RTMP_INGEST_URL', 'rtmp://nyone.app:19935'),
|
||||
'hls_public_url' => env('STREAMING_HLS_PUBLIC_URL', 'https://nyone-hls.net'),
|
||||
|
||||
/*
|
||||
|
||||
@@ -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,20 +15,9 @@ 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']);
|
||||
$admin = User::query()->firstOrNew(['email' => 'admin@nyone.app']);
|
||||
$admin->forceFill([
|
||||
'name' => 'Admin User',
|
||||
'password' => 'password',
|
||||
@@ -37,7 +26,7 @@ class DatabaseSeeder extends Seeder
|
||||
'can_create_channel' => false,
|
||||
])->save();
|
||||
|
||||
$official = User::query()->firstOrNew(['email' => 'chief@nyone.net']);
|
||||
$official = User::query()->firstOrNew(['email' => 'chief@nyone.app']);
|
||||
$official->forceFill([
|
||||
'name' => 'Chief Nyone',
|
||||
'password' => 'password',
|
||||
@@ -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.',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
authMethod: http
|
||||
authHTTPAddress: https://nyone.net/internal/mediamtx/auth?secret=change-this-secret
|
||||
authHTTPAddress: https://nyone.app/internal/mediamtx/auth?secret=change-this-secret
|
||||
authHTTPExclude:
|
||||
- action: read
|
||||
- action: playback
|
||||
@@ -17,17 +17,17 @@ hls: yes
|
||||
hlsAddress: :19988
|
||||
hlsVariant: mpegts
|
||||
hlsAllowOrigins:
|
||||
- https://nyone.net
|
||||
- https://nyone.app
|
||||
|
||||
|
||||
pathDefaults:
|
||||
record: yes
|
||||
recordPath: ./storage/app/private/recordings/%path/%Y-%m-%d_%H-%M-%S-%f
|
||||
|
||||
runOnReady: curl -X POST "https://nyone.net/internal/mediamtx/ready?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
|
||||
runOnReady: curl -X POST "https://nyone.app/internal/mediamtx/ready?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
|
||||
runOnReadyRestart: no
|
||||
runOnNotReady: curl -X POST "https://nyone.net/internal/mediamtx/not-ready?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
|
||||
runOnRecordSegmentComplete: curl -X POST "https://nyone.net/internal/mediamtx/recording-complete?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "segment_path=$MTX_SEGMENT_PATH" --data-urlencode "duration=$MTX_SEGMENT_DURATION"
|
||||
runOnNotReady: curl -X POST "https://nyone.app/internal/mediamtx/not-ready?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
|
||||
runOnRecordSegmentComplete: curl -X POST "https://nyone.app/internal/mediamtx/recording-complete?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "segment_path=$MTX_SEGMENT_PATH" --data-urlencode "duration=$MTX_SEGMENT_DURATION"
|
||||
|
||||
paths:
|
||||
all_others:
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
These configs match the current project domains:
|
||||
|
||||
- App: `https://nyone.net`
|
||||
- App: `https://nyone.app`
|
||||
- HLS playback: `https://nyone-hls.net`
|
||||
- OBS ingest: `rtmp://nyone.net:19935`
|
||||
- OBS ingest: `rtmp://nyone.app:19935`
|
||||
|
||||
MediaMTX stays private on local ports:
|
||||
|
||||
@@ -29,14 +29,14 @@ sudo systemctl reload nginx
|
||||
|
||||
Make sure DNS points both hostnames to the server:
|
||||
|
||||
- `nyone.net`
|
||||
- `nyone.app`
|
||||
- `nyone-hls.net`
|
||||
|
||||
The Laravel `.env` should use:
|
||||
|
||||
```env
|
||||
APP_URL=https://nyone.net
|
||||
STREAMING_RTMP_INGEST_URL=rtmp://nyone.net:19935
|
||||
APP_URL=https://nyone.app
|
||||
STREAMING_RTMP_INGEST_URL=rtmp://nyone.app:19935
|
||||
STREAMING_HLS_PUBLIC_URL=https://nyone-hls.net
|
||||
MEDIAMTX_SHARED_SECRET=change-this-secret
|
||||
```
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Laravel application vhost for nyone.net.
|
||||
# Laravel application vhost for nyone.app.
|
||||
# Copy to /etc/nginx/sites-available/nyone-app.conf and symlink into sites-enabled.
|
||||
# Adjust root and fastcgi_pass for your server.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name nyone.net;
|
||||
server_name nyone.app;
|
||||
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
@@ -13,13 +13,13 @@ server {
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name nyone.net;
|
||||
server_name nyone.app;
|
||||
|
||||
root /var/www/nyone/public;
|
||||
index index.php;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/nyone.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/nyone.net/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/nyone.app/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/nyone.app/privkey.pem;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# HLS reverse proxy for MediaMTX.
|
||||
# Browser origin is https://nyone.net, while playback URLs point here:
|
||||
# Browser origin is https://nyone.app, while playback URLs point here:
|
||||
# https://nyone-hls.net/<channel-slug>/index.m3u8
|
||||
|
||||
server {
|
||||
|
||||
13
docker-bundle/.dockerignore
Normal file
13
docker-bundle/.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
|
||||
|
||||
49
docker-bundle/.env.example
Normal file
49
docker-bundle/.env.example
Normal file
@@ -0,0 +1,49 @@
|
||||
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
|
||||
HLS_HTTP_PORT=127.0.0.1:8888
|
||||
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
docker-bundle/.gitignore
vendored
Normal file
3
docker-bundle/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
runtime/
|
||||
|
||||
253
docker-bundle/README.md
Normal file
253
docker-bundle/README.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# Nyone Docker Bundle Deployment
|
||||
|
||||
This folder is a self-contained deployment bundle. A server only needs the `docker-bundle/` 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 proxy: `80`
|
||||
- RTMP ingest: `1935`
|
||||
- MediaMTX HLS on localhost: `8888`
|
||||
- MediaMTX API inside Docker: `9997`
|
||||
- PostgreSQL inside Docker: `5432`
|
||||
- Redis inside Docker: `6379`
|
||||
|
||||
HTTP and RTMP are published by default. HLS is published only on `127.0.0.1:8888` by default so host Nginx can proxy `HLS_DOMAIN` directly to MediaMTX without exposing the HLS port publicly.
|
||||
|
||||
## Server Requirements
|
||||
|
||||
- Docker with Compose v2.
|
||||
- Git.
|
||||
- OpenSSL.
|
||||
- Zip.
|
||||
- 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 for the app and keep `HLS_HTTP_PORT` on a local MediaMTX port for HLS.
|
||||
|
||||
## First Deployment
|
||||
|
||||
```bash
|
||||
cd docker-bundle
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
chmod +x scripts/deploy.sh
|
||||
chmod +x scripts/backup.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 `docker-bundle/.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`.
|
||||
- `HLS_HTTP_PORT`: host address published by MediaMTX HLS, default `127.0.0.1:8888`.
|
||||
- `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 `docker-bundle/` 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
|
||||
```
|
||||
|
||||
Create a restore-ready backup zip:
|
||||
|
||||
```bash
|
||||
./scripts/backup.sh
|
||||
```
|
||||
|
||||
For the most consistent app storage and recording backup, run it during a maintenance window and let the script stop writer services while it archives files:
|
||||
|
||||
```bash
|
||||
./scripts/backup.sh --stop-writers
|
||||
```
|
||||
|
||||
Backups are written to `backups/` by default. Each zip contains deployment files, `.env`, `runtime/source`, PostgreSQL dump, app storage, Redis data, checksums, restore notes, and a `restore.sh` helper.
|
||||
|
||||
Restore on a fresh server after installing Docker and Compose:
|
||||
|
||||
```bash
|
||||
unzip nyone-backup-YYYYMMDD-HHMMSS.zip
|
||||
cd nyone-backup-YYYYMMDD-HHMMSS
|
||||
chmod +x restore.sh
|
||||
./restore.sh /opt/nyone/docker-bundle
|
||||
```
|
||||
|
||||
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 `APP_DOMAIN` to the Apache container's published HTTP port. Forward `HLS_DOMAIN` directly to the MediaMTX HLS localhost port. Preserve the `Host` header for the Laravel app 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
|
||||
HLS_HTTP_PORT=127.0.0.1:8888
|
||||
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 app traffic at `http://127.0.0.1:8080` and HLS traffic at `http://127.0.0.1:8888`. Replace the example domains and certificate paths before enabling the config.
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/nyone-docker-bundle.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 / {
|
||||
if ($request_method = OPTIONS) {
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_pass http://127.0.0.1:8888;
|
||||
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 Access-Control-Allow-Origin "https://app.example.com" always;
|
||||
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Range, Origin, Accept, Content-Type" always;
|
||||
add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;
|
||||
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-docker-bundle.conf /etc/nginx/sites-enabled/nyone-docker-bundle.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.
|
||||
|
||||
## HLS CORS Check
|
||||
|
||||
Host Nginx owns the public HLS CORS headers. MediaMTX is configured with `hlsAllowOrigins: []` so the response does not include duplicate `Access-Control-Allow-Origin` headers after proxying.
|
||||
|
||||
Check the deployed headers with the exact app origin:
|
||||
|
||||
```bash
|
||||
curl -I -H "Origin: https://app.example.com" "https://hls.example.com/channel/index.m3u8"
|
||||
```
|
||||
|
||||
The response should contain one `Access-Control-Allow-Origin` header matching the app origin. If two `Access-Control-Allow-Origin` headers appear, rebuild the MediaMTX container from this bundle and reload Nginx.
|
||||
79
docker-bundle/SKILL.md
Normal file
79
docker-bundle/SKILL.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: docker-bundle-deployment
|
||||
description: Work on the Nyone docker-bundle deployment bundle under docker-bundle/. 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 docker-bundle Deployment
|
||||
|
||||
## Core Rules
|
||||
|
||||
- Keep deployment changes inside `docker-bundle/` unless the user explicitly requests application code changes.
|
||||
- Treat `docker-bundle/.env` and `docker-bundle/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 the app and HLS are served by different host-level Nginx 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.
|
||||
- `scripts/backup.sh` creates restore-ready zip backups for the docker-bundle 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`.
|
||||
- Host Nginx should proxy `HLS_DOMAIN` directly to the locally published MediaMTX HLS port.
|
||||
- `docker/mediamtx/entrypoint.sh` renders the MediaMTX template at container start.
|
||||
|
||||
## Port Defaults
|
||||
|
||||
- Apache published HTTP: `80`.
|
||||
- MediaMTX RTMP: `1935`.
|
||||
- MediaMTX HLS local publish/internal: `8888`.
|
||||
- MediaMTX API internal: `9997`.
|
||||
- PostgreSQL internal: `5432`.
|
||||
- Redis internal: `6379`.
|
||||
|
||||
Publish Apache HTTP and RTMP by default, and bind MediaMTX HLS to `127.0.0.1:8888` for host Nginx. Keep the MediaMTX 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`.
|
||||
- Do not run the long-lived Docker `viewer-sync` service with `--isolated`; a stale cache lock can make the command exit cleanly and trigger an endless container restart loop.
|
||||
- Keep `npm run build` after PHP setup and Composer install in `docker/app/Dockerfile`; the Wayfinder Vite plugin invokes `php artisan wayfinder:generate` during the build. The asset stage must also provide safe build-time Laravel env values, create Laravel writable directories such as `storage/framework/views`, and run `php artisan wayfinder:generate --with-form --no-interaction` before `npm run build` so failures are visible in Docker logs.
|
||||
- Do not call `php artisan storage:link --force` as `www-data` during container startup; `public/storage` lives in the root-owned image filesystem. Create the symlink as root in the entrypoint if it is missing.
|
||||
- Keep deploy script behavior idempotent: repeat runs should update the Git checkout, rebuild, migrate, and restart services without deleting volumes.
|
||||
- Keep backup behavior restore-ready: include deployment files, `.env`, source checkout when present, PostgreSQL dump, app storage volume, Redis volume, checksums, restore notes, and a restore helper in one zip.
|
||||
- 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 the app hostname only. Nginx should proxy HLS directly to the locally published MediaMTX HLS port.
|
||||
- Recommend `APP_HTTP_PORT=127.0.0.1:8080` when Nginx owns host ports `80` and `443`.
|
||||
- Recommend `HLS_HTTP_PORT=127.0.0.1:8888` so HLS is reachable by host Nginx but not publicly exposed as a raw port.
|
||||
- Include `X-Forwarded-Proto https`, `X-Forwarded-Port 443`, `X-Forwarded-Host`, `X-Real-IP`, and `X-Forwarded-For` in proxy examples.
|
||||
- Let host Nginx own public HLS CORS headers. Keep `hlsAllowOrigins: []` in the MediaMTX template so proxied HLS responses do not contain duplicate `Access-Control-Allow-Origin` values.
|
||||
- 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]" docker-bundle -n
|
||||
bash -n docker-bundle/scripts/backup.sh
|
||||
```
|
||||
|
||||
If Docker is available and the user allows non-deploy validation, validate only the Compose config:
|
||||
|
||||
```bash
|
||||
docker compose --env-file docker-bundle/.env.example -f docker-bundle/docker-compose.yml config
|
||||
```
|
||||
|
||||
Do not start containers during local verification unless the user explicitly asks for runtime testing.
|
||||
492
docker-bundle/UBUNTU_VPS_NYONE_APP_DEPLOYMENT.md
Normal file
492
docker-bundle/UBUNTU_VPS_NYONE_APP_DEPLOYMENT.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# Fresh Ubuntu VPS Deployment for nyone.app
|
||||
|
||||
This guide explains how to deploy Nyone from only the `docker-bundle/` folder on a fresh Ubuntu VPS.
|
||||
|
||||
It assumes:
|
||||
|
||||
- App domain: `nyone.app`
|
||||
- HLS playback domain: `hls.nyone.app`
|
||||
- RTMP ingest URL: `rtmp://nyone.app:1935`
|
||||
- Docker Apache is private on `127.0.0.1:8080`
|
||||
- Docker MediaMTX HLS is private on `127.0.0.1:8888`
|
||||
- Host Nginx handles public HTTP, HTTPS, and SSL certificates
|
||||
|
||||
The application repository itself does not need to exist on the server before deployment. The deploy script clones it into `docker-bundle/runtime/source`.
|
||||
|
||||
## 1. Point DNS to the VPS
|
||||
|
||||
Create DNS records before requesting SSL certificates:
|
||||
|
||||
```text
|
||||
nyone.app A <VPS_PUBLIC_IPV4>
|
||||
hls.nyone.app A <VPS_PUBLIC_IPV4>
|
||||
```
|
||||
|
||||
If your DNS provider has an HTTP proxy mode, make sure TCP `1935` still reaches the VPS directly for RTMP. With Cloudflare, that usually means the RTMP hostname must be DNS-only, not proxied.
|
||||
|
||||
Wait until DNS resolves from the VPS or your local machine:
|
||||
|
||||
```bash
|
||||
dig +short nyone.app
|
||||
dig +short hls.nyone.app
|
||||
```
|
||||
|
||||
## 2. SSH into the VPS
|
||||
|
||||
```bash
|
||||
ssh root@<VPS_PUBLIC_IPV4>
|
||||
```
|
||||
|
||||
Update the server:
|
||||
|
||||
```bash
|
||||
apt-get update
|
||||
apt-get upgrade -y
|
||||
apt-get install -y ca-certificates curl git gnupg lsb-release nginx openssl snapd ufw unzip zip
|
||||
```
|
||||
|
||||
## 3. Configure the firewall
|
||||
|
||||
Allow SSH, HTTP/HTTPS, and RTMP:
|
||||
|
||||
```bash
|
||||
ufw allow OpenSSH
|
||||
ufw allow 'Nginx Full'
|
||||
ufw allow 1935/tcp
|
||||
ufw --force enable
|
||||
ufw status
|
||||
```
|
||||
|
||||
Docker can publish container ports outside some UFW rules. In this deployment, Apache is bound to `127.0.0.1:8080`, MediaMTX HLS is bound to `127.0.0.1:8888`, and RTMP is intentionally public on `1935`.
|
||||
|
||||
## 4. Install Docker Engine and Compose plugin
|
||||
|
||||
Remove conflicting packages if they exist:
|
||||
|
||||
```bash
|
||||
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
|
||||
apt-get remove -y "$pkg" || true
|
||||
done
|
||||
```
|
||||
|
||||
Install Docker from Docker's official Ubuntu apt repository:
|
||||
|
||||
```bash
|
||||
apt-get update
|
||||
apt-get install -y ca-certificates curl
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
|
||||
tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOF
|
||||
Types: deb
|
||||
URIs: https://download.docker.com/linux/ubuntu
|
||||
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
|
||||
Components: stable
|
||||
Architectures: $(dpkg --print-architecture)
|
||||
Signed-By: /etc/apt/keyrings/docker.asc
|
||||
EOF
|
||||
|
||||
apt-get update
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
systemctl enable --now docker
|
||||
docker run hello-world
|
||||
```
|
||||
|
||||
## 5. Copy only the docker-bundle folder to the server
|
||||
|
||||
Create the deployment directory on the VPS:
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/nyone
|
||||
```
|
||||
|
||||
From your local machine, copy the folder:
|
||||
|
||||
```bash
|
||||
rsync -av --exclude '.env' --exclude 'runtime' docker-bundle/ root@<VPS_PUBLIC_IPV4>:/opt/nyone/docker-bundle/
|
||||
```
|
||||
|
||||
Back on the VPS:
|
||||
|
||||
```bash
|
||||
cd /opt/nyone/docker-bundle
|
||||
cp .env.example .env
|
||||
chmod +x scripts/deploy.sh
|
||||
chmod +x scripts/backup.sh
|
||||
```
|
||||
|
||||
## 6. Configure docker-bundle/.env
|
||||
|
||||
Edit the Docker deployment environment:
|
||||
|
||||
```bash
|
||||
nano /opt/nyone/docker-bundle/.env
|
||||
```
|
||||
|
||||
Use these values for `nyone.app`:
|
||||
|
||||
```env
|
||||
COMPOSE_PROJECT_NAME=nyone
|
||||
|
||||
GIT_REPOSITORY_URL=git@github.com:your-org/nyone.git
|
||||
GIT_REF=main
|
||||
|
||||
PUBLIC_SCHEME=https
|
||||
APP_DOMAIN=nyone.app
|
||||
HLS_DOMAIN=hls.nyone.app
|
||||
APP_HTTP_PORT=127.0.0.1:8080
|
||||
HLS_HTTP_PORT=127.0.0.1:8888
|
||||
RTMP_PUBLIC_PORT=1935
|
||||
|
||||
APP_NAME=Nyone
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
|
||||
POSTGRES_DB=nyone
|
||||
POSTGRES_USER=nyone
|
||||
|
||||
SEED_DATABASE=false
|
||||
APP_OPTIMIZE_ON_BOOT=true
|
||||
QUEUE_NAMES=default
|
||||
```
|
||||
|
||||
Leave these empty on first deploy if you want the deploy script to generate them:
|
||||
|
||||
```env
|
||||
APP_KEY=
|
||||
POSTGRES_PASSWORD=
|
||||
MEDIAMTX_SHARED_SECRET=
|
||||
```
|
||||
|
||||
If the repository is public, you can use an HTTPS clone URL instead:
|
||||
|
||||
```env
|
||||
GIT_REPOSITORY_URL=https://github.com/your-org/nyone.git
|
||||
```
|
||||
|
||||
If `GIT_REPOSITORY_URL` is private, install a deploy key on the VPS:
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -C "nyone-vps-deploy" -f ~/.ssh/nyone_deploy
|
||||
cat ~/.ssh/nyone_deploy.pub
|
||||
```
|
||||
|
||||
Add the printed public key as a read-only deploy key in the Git provider, then configure SSH:
|
||||
|
||||
```bash
|
||||
cat >> ~/.ssh/config <<'EOF'
|
||||
Host github.com
|
||||
HostName github.com
|
||||
User git
|
||||
IdentityFile ~/.ssh/nyone_deploy
|
||||
IdentitiesOnly yes
|
||||
EOF
|
||||
|
||||
chmod 600 ~/.ssh/config ~/.ssh/nyone_deploy
|
||||
```
|
||||
|
||||
Confirm Git access:
|
||||
|
||||
```bash
|
||||
ssh -T git@github.com
|
||||
```
|
||||
|
||||
## 7. Run the Docker deployment
|
||||
|
||||
```bash
|
||||
cd /opt/nyone/docker-bundle
|
||||
./scripts/deploy.sh
|
||||
```
|
||||
|
||||
The script will clone the app into `runtime/source`, build the images, run migrations, and start:
|
||||
|
||||
- `app`
|
||||
- `db`
|
||||
- `redis`
|
||||
- `mediamtx`
|
||||
- `queue`
|
||||
- `scheduler`
|
||||
- `viewer-sync`
|
||||
|
||||
Check status:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env -f docker-compose.yml ps
|
||||
docker compose --env-file .env -f docker-compose.yml logs --tail=100 app
|
||||
docker compose --env-file .env -f docker-compose.yml logs --tail=100 mediamtx
|
||||
```
|
||||
|
||||
## 8. Install Certbot
|
||||
|
||||
Install Certbot through snap:
|
||||
|
||||
```bash
|
||||
snap install core
|
||||
snap refresh core
|
||||
apt-get remove -y certbot || true
|
||||
snap install --classic certbot
|
||||
ln -sf /snap/bin/certbot /usr/local/bin/certbot
|
||||
```
|
||||
|
||||
Create the webroot used for certificate challenges:
|
||||
|
||||
```bash
|
||||
mkdir -p /var/www/certbot
|
||||
```
|
||||
|
||||
## 9. Create temporary Nginx config for SSL issuance
|
||||
|
||||
```bash
|
||||
tee /etc/nginx/sites-available/nyone-temp.conf >/dev/null <<'EOF'
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name nyone.app hls.nyone.app;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 200 "nyone certificate setup\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
ln -sf /etc/nginx/sites-available/nyone-temp.conf /etc/nginx/sites-enabled/nyone-temp.conf
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
```
|
||||
|
||||
Request the certificate:
|
||||
|
||||
```bash
|
||||
certbot certonly --webroot \
|
||||
-w /var/www/certbot \
|
||||
-d nyone.app \
|
||||
-d hls.nyone.app
|
||||
```
|
||||
|
||||
The certificate files should be under:
|
||||
|
||||
```text
|
||||
/etc/letsencrypt/live/nyone.app/fullchain.pem
|
||||
/etc/letsencrypt/live/nyone.app/privkey.pem
|
||||
```
|
||||
|
||||
## 10. Install final Nginx proxy config
|
||||
|
||||
```bash
|
||||
tee /etc/nginx/sites-available/nyone.conf >/dev/null <<'EOF'
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name nyone.app hls.nyone.app;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name nyone.app;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/nyone.app/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/nyone.app/privkey.pem;
|
||||
|
||||
client_max_body_size 100M;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
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.nyone.app;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/nyone.app/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/nyone.app/privkey.pem;
|
||||
|
||||
location / {
|
||||
if ($request_method = OPTIONS) {
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_pass http://127.0.0.1:8888;
|
||||
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 Access-Control-Allow-Origin "https://nyone.app" always;
|
||||
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Range, Origin, Accept, Content-Type" always;
|
||||
add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
ln -sf /etc/nginx/sites-available/nyone.conf /etc/nginx/sites-enabled/nyone.conf
|
||||
rm -f /etc/nginx/sites-enabled/nyone-temp.conf
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
```
|
||||
|
||||
## 11. Test certificate renewal
|
||||
|
||||
```bash
|
||||
certbot renew --dry-run
|
||||
```
|
||||
|
||||
## 12. Verify the deployment
|
||||
|
||||
Check the app health endpoint:
|
||||
|
||||
```bash
|
||||
curl -I https://nyone.app/up
|
||||
```
|
||||
|
||||
Check container status:
|
||||
|
||||
```bash
|
||||
cd /opt/nyone/docker-bundle
|
||||
docker compose --env-file .env -f docker-compose.yml ps
|
||||
```
|
||||
|
||||
Check database migrations:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env -f docker-compose.yml exec app php artisan migrate:status
|
||||
```
|
||||
|
||||
Run one viewer-count sync pass:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env -f docker-compose.yml exec app php artisan streaming:sync-viewer-counts --once -v
|
||||
```
|
||||
|
||||
Check HLS CORS headers:
|
||||
|
||||
```bash
|
||||
curl -I -H "Origin: https://nyone.app" "https://hls.nyone.app/<channel-slug>/index.m3u8"
|
||||
```
|
||||
|
||||
The response should include exactly one `Access-Control-Allow-Origin: https://nyone.app` header. A `404` can be normal when the channel is not currently publishing, but duplicate `Access-Control-Allow-Origin` headers mean both Nginx and an upstream are setting CORS; rebuild the MediaMTX container from this bundle and reload Nginx.
|
||||
|
||||
Check that MediaMTX API is not exposed on the host. This should fail because the API is only available inside the Docker network:
|
||||
|
||||
```bash
|
||||
curl -I http://127.0.0.1:9997/v3/config/get
|
||||
```
|
||||
|
||||
Do not expose port `9997` on the public firewall.
|
||||
|
||||
## 13. OBS and playback values
|
||||
|
||||
Creators should use:
|
||||
|
||||
```text
|
||||
OBS server: rtmp://nyone.app:1935
|
||||
OBS stream key: <channel-slug>?token=<generated-stream-key>
|
||||
```
|
||||
|
||||
Viewer playback URLs generated by Laravel should look like:
|
||||
|
||||
```text
|
||||
https://hls.nyone.app/<channel-slug>/index.m3u8
|
||||
```
|
||||
|
||||
## 14. Redeploy after app changes
|
||||
|
||||
When the Git branch changes:
|
||||
|
||||
```bash
|
||||
cd /opt/nyone/docker-bundle
|
||||
./scripts/deploy.sh
|
||||
```
|
||||
|
||||
Useful logs:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env -f docker-compose.yml logs -f app
|
||||
docker compose --env-file .env -f docker-compose.yml logs -f queue
|
||||
docker compose --env-file .env -f docker-compose.yml logs -f scheduler
|
||||
docker compose --env-file .env -f docker-compose.yml logs -f viewer-sync
|
||||
docker compose --env-file .env -f docker-compose.yml logs -f mediamtx
|
||||
```
|
||||
|
||||
## 15. Create a restore-ready backup
|
||||
|
||||
Run backups from the docker-bundle deployment directory:
|
||||
|
||||
```bash
|
||||
cd /opt/nyone/docker-bundle
|
||||
./scripts/backup.sh
|
||||
```
|
||||
|
||||
For the safest storage and recording snapshot, use a maintenance window and stop writer services while the backup runs:
|
||||
|
||||
```bash
|
||||
./scripts/backup.sh --stop-writers
|
||||
```
|
||||
|
||||
The final archive is written to:
|
||||
|
||||
```text
|
||||
/opt/nyone/docker-bundle/backups/nyone-backup-YYYYMMDD-HHMMSS.zip
|
||||
```
|
||||
|
||||
Copy that one zip file off the VPS. It contains the docker-bundle deployment files, `.env`, source checkout, PostgreSQL dump, app storage, Redis data, checksums, restore notes, and a restore helper.
|
||||
|
||||
Restore on a fresh Ubuntu VPS after installing Docker and copying the zip:
|
||||
|
||||
```bash
|
||||
unzip nyone-backup-YYYYMMDD-HHMMSS.zip
|
||||
cd nyone-backup-YYYYMMDD-HHMMSS
|
||||
chmod +x restore.sh
|
||||
./restore.sh /opt/nyone/docker-bundle
|
||||
```
|
||||
|
||||
If `/opt/nyone/docker-bundle` already exists and you intentionally want to replace it:
|
||||
|
||||
```bash
|
||||
./restore.sh --replace /opt/nyone/docker-bundle
|
||||
```
|
||||
|
||||
Host-level Nginx files and Let's Encrypt certificates live outside `docker-bundle/`, so recreate or back them up separately.
|
||||
|
||||
## References
|
||||
|
||||
- Docker Engine on Ubuntu: https://docs.docker.com/engine/install/ubuntu/
|
||||
- Certbot Nginx instructions: https://certbot.eff.org/instructions
|
||||
- Laravel trusted proxy behavior: https://laravel.com/docs/13.x/requests#configuring-trusted-proxies
|
||||
186
docker-bundle/docker-compose.yml
Normal file
186
docker-bundle/docker-compose.yml
Normal file
@@ -0,0 +1,186 @@
|
||||
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}
|
||||
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:
|
||||
MEDIAMTX_SHARED_SECRET: ${MEDIAMTX_SHARED_SECRET:-}
|
||||
RTMP_INTERNAL_PORT: 1935
|
||||
HLS_INTERNAL_PORT: 8888
|
||||
ports:
|
||||
- "${RTMP_PUBLIC_PORT:-1935}:1935"
|
||||
- "${HLS_HTTP_PORT:-127.0.0.1:8888}:8888"
|
||||
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:
|
||||
121
docker-bundle/docker/app/Dockerfile
Normal file
121
docker-bundle/docker/app/Dockerfile
Normal file
@@ -0,0 +1,121 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM php:8.4-apache-bookworm AS php-base
|
||||
|
||||
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 \
|
||||
libsqlite3-dev \
|
||||
libzip-dev \
|
||||
netcat-openbsd \
|
||||
unzip \
|
||||
&& docker-php-ext-install -j"$(nproc)" \
|
||||
bcmath \
|
||||
curl \
|
||||
intl \
|
||||
mbstring \
|
||||
opcache \
|
||||
pcntl \
|
||||
pdo_pgsql \
|
||||
pdo_sqlite \
|
||||
pgsql \
|
||||
zip \
|
||||
&& pecl install redis \
|
||||
&& docker-php-ext-enable redis \
|
||||
&& a2enmod headers rewrite setenvif \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
FROM php-base AS composer-deps
|
||||
|
||||
COPY runtime/source/composer.json runtime/source/composer.lock ./
|
||||
RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-scripts
|
||||
|
||||
FROM php-base AS assets
|
||||
|
||||
ENV APP_NAME=Nyone \
|
||||
APP_ENV=production \
|
||||
APP_DEBUG=false \
|
||||
APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA \
|
||||
APP_URL=http://localhost \
|
||||
LOG_CHANNEL=stderr \
|
||||
DB_CONNECTION=sqlite \
|
||||
DB_DATABASE=:memory: \
|
||||
CACHE_STORE=array \
|
||||
SESSION_DRIVER=array \
|
||||
QUEUE_CONNECTION=sync \
|
||||
MAIL_MAILER=log \
|
||||
BROADCAST_CONNECTION=log \
|
||||
FILESYSTEM_DISK=local \
|
||||
VITE_APP_NAME=Nyone \
|
||||
STREAMING_VIEWER_COUNT_CACHE_STORE=array \
|
||||
MEDIAMTX_SHARED_SECRET=build-time-secret
|
||||
|
||||
COPY --from=node:22-bookworm-slim /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node:22-bookworm-slim /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||
&& ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
COPY runtime/source/composer.json runtime/source/composer.lock ./
|
||||
COPY --from=composer-deps /var/www/html/vendor ./vendor
|
||||
|
||||
COPY runtime/source/package.json runtime/source/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY runtime/source/ ./
|
||||
RUN mkdir -p \
|
||||
storage/app/private/recordings \
|
||||
storage/app/public \
|
||||
storage/framework/cache/data \
|
||||
storage/framework/sessions \
|
||||
storage/framework/views \
|
||||
storage/logs \
|
||||
bootstrap/cache \
|
||||
&& composer dump-autoload --optimize --no-dev \
|
||||
&& php artisan package:discover --ansi \
|
||||
&& php artisan route:clear \
|
||||
&& php artisan wayfinder:generate --with-form --no-interaction \
|
||||
&& npm run build
|
||||
|
||||
FROM php-base AS app
|
||||
|
||||
COPY runtime/source/composer.json runtime/source/composer.lock ./
|
||||
COPY --from=composer-deps /var/www/html/vendor ./vendor
|
||||
|
||||
COPY runtime/source/ ./
|
||||
COPY --from=assets /var/www/html/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 \
|
||||
&& if [ ! -e public/storage ]; then ln -s ../storage/app/public public/storage; fi \
|
||||
&& chown -R www-data:www-data storage bootstrap/cache \
|
||||
&& chmod +x /usr/local/bin/nyone-entrypoint
|
||||
|
||||
ENTRYPOINT ["nyone-entrypoint"]
|
||||
CMD ["app"]
|
||||
24
docker-bundle/docker/app/apache/nyone.conf.template
Normal file
24
docker-bundle/docker/app/apache/nyone.conf.template
Normal file
@@ -0,0 +1,24 @@
|
||||
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>
|
||||
143
docker-bundle/docker/app/entrypoint.sh
Normal file
143
docker-bundle/docker/app/entrypoint.sh
Normal file
@@ -0,0 +1,143 @@
|
||||
#!/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 docker-bundle/scripts/deploy.sh or set it in docker-bundle/.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
|
||||
|
||||
envsubst '${APP_DOMAIN}' \
|
||||
< /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
|
||||
}
|
||||
|
||||
ensure_public_storage_link() {
|
||||
mkdir -p public storage/app/public
|
||||
|
||||
if [[ -L public/storage || -e public/storage ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
ln -s ../storage/app/public public/storage
|
||||
}
|
||||
|
||||
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
|
||||
ensure_public_storage_link
|
||||
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 \
|
||||
--interval="${STREAMING_VIEWER_SYNC_INTERVAL:-2}"
|
||||
;;
|
||||
artisan)
|
||||
shift || true
|
||||
exec gosu www-data php artisan "$@"
|
||||
;;
|
||||
*)
|
||||
exec "$@"
|
||||
;;
|
||||
esac
|
||||
17
docker-bundle/docker/app/php/production.ini
Normal file
17
docker-bundle/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
docker-bundle/docker/mediamtx/Dockerfile
Normal file
17
docker-bundle/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"]
|
||||
|
||||
32
docker-bundle/docker/mediamtx/entrypoint.sh
Normal file
32
docker-bundle/docker/mediamtx/entrypoint.sh
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/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 docker-bundle/scripts/deploy.sh or set it in docker-bundle/.env."
|
||||
fi
|
||||
|
||||
: "${RTMP_INTERNAL_PORT:=1935}"
|
||||
: "${HLS_INTERNAL_PORT:=8888}"
|
||||
|
||||
mkdir -p /config /var/www/html/storage/app/private/recordings
|
||||
|
||||
export MEDIAMTX_SHARED_SECRET RTMP_INTERNAL_PORT HLS_INTERNAL_PORT
|
||||
|
||||
envsubst '${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."
|
||||
32
docker-bundle/docker/mediamtx/mediamtx.yml.template
Normal file
32
docker-bundle/docker/mediamtx/mediamtx.yml.template
Normal file
@@ -0,0 +1,32 @@
|
||||
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: []
|
||||
|
||||
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
|
||||
452
docker-bundle/scripts/backup.sh
Normal file
452
docker-bundle/scripts/backup.sh
Normal file
@@ -0,0 +1,452 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ENV_FILE="$ROOT_DIR/.env"
|
||||
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
|
||||
SOURCE_DIR="$ROOT_DIR/runtime/source"
|
||||
BACKUP_ROOT="$ROOT_DIR/backups"
|
||||
BACKUP_NAME="nyone-backup-$(date -u +%Y%m%d-%H%M%S)"
|
||||
STOP_WRITERS=false
|
||||
FORCE=false
|
||||
STAGE_DIR=""
|
||||
RESTART_WRITERS=false
|
||||
|
||||
info() {
|
||||
printf '[nyone-backup] %s\n' "$*"
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf '[nyone-backup] WARNING: %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[nyone-backup] ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
scripts/backup.sh [options]
|
||||
|
||||
Options:
|
||||
--output-dir PATH Directory for the final zip file. Default: docker-bundle/backups
|
||||
--name NAME Backup folder/zip base name. Default: nyone-backup-UTC_TIMESTAMP
|
||||
--stop-writers Stop app, MediaMTX, queue, scheduler, and viewer-sync while files are archived
|
||||
--force Replace an existing zip with the same name
|
||||
-h, --help Show this help
|
||||
|
||||
The script creates one restore-ready zip containing:
|
||||
- docker-bundle deployment files and .env
|
||||
- runtime/source checkout when present, excluding dependencies and Laravel runtime cache/log files
|
||||
- PostgreSQL SQL dump
|
||||
- app storage volume archive
|
||||
- Redis data volume archive
|
||||
- restore.sh and README_RESTORE.md
|
||||
USAGE
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
run_compose() {
|
||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
|
||||
if [ "$RESTART_WRITERS" = true ]; then
|
||||
info "Restarting writer services."
|
||||
run_compose up -d app mediamtx queue scheduler viewer-sync >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [ -n "$STAGE_DIR" ] && [[ "$STAGE_DIR" == "$BACKUP_ROOT"/.*.stage ]]; then
|
||||
rm -rf "$STAGE_DIR"
|
||||
fi
|
||||
|
||||
exit "$exit_code"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--output-dir)
|
||||
[ "$#" -ge 2 ] || fail "--output-dir requires a value."
|
||||
BACKUP_ROOT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--name)
|
||||
[ "$#" -ge 2 ] || fail "--name requires a value."
|
||||
BACKUP_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--stop-writers)
|
||||
STOP_WRITERS=true
|
||||
shift
|
||||
;;
|
||||
--force)
|
||||
FORCE=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
fail "Unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$BACKUP_NAME" in
|
||||
*/*|*\\*)
|
||||
fail "--name must be a file name, not a path."
|
||||
;;
|
||||
esac
|
||||
|
||||
[ -f "$ENV_FILE" ] || fail "$ENV_FILE was not found. Create it before running a backup."
|
||||
[ -f "$COMPOSE_FILE" ] || fail "$COMPOSE_FILE was not found."
|
||||
|
||||
require_command docker
|
||||
require_command tar
|
||||
require_command gzip
|
||||
require_command zip
|
||||
require_command sha256sum
|
||||
|
||||
mkdir -p "$BACKUP_ROOT"
|
||||
BACKUP_ROOT="$(cd "$BACKUP_ROOT" && pwd)"
|
||||
STAGE_DIR="$BACKUP_ROOT/.${BACKUP_NAME}.stage"
|
||||
ARCHIVE_DIR="$STAGE_DIR/$BACKUP_NAME"
|
||||
ZIP_PATH="$BACKUP_ROOT/$BACKUP_NAME.zip"
|
||||
|
||||
if [ -e "$ZIP_PATH" ] && [ "$FORCE" != true ]; then
|
||||
fail "$ZIP_PATH already exists. Use --force or choose another --name."
|
||||
fi
|
||||
|
||||
rm -rf "$STAGE_DIR"
|
||||
mkdir -p "$ARCHIVE_DIR"/{database,deployment,metadata,volumes}
|
||||
|
||||
info "Validating Compose configuration."
|
||||
run_compose config > "$ARCHIVE_DIR/metadata/compose.resolved.yml"
|
||||
|
||||
if [ "$STOP_WRITERS" = true ]; then
|
||||
info "Stopping writer services for a consistent file backup."
|
||||
run_compose stop app mediamtx queue scheduler viewer-sync >/dev/null || true
|
||||
RESTART_WRITERS=true
|
||||
fi
|
||||
|
||||
info "Writing PostgreSQL dump."
|
||||
run_compose exec -T db sh -c 'PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges' \
|
||||
| gzip -9 > "$ARCHIVE_DIR/database/postgres.sql.gz"
|
||||
|
||||
info "Archiving app storage volume."
|
||||
run_compose run --rm --no-deps -T --user root --entrypoint tar \
|
||||
-v "$ARCHIVE_DIR/volumes:/backup" \
|
||||
app -czf /backup/app-storage.tar.gz -C /var/www/html/storage .
|
||||
|
||||
info "Archiving Redis data volume."
|
||||
if ! run_compose exec -T redis redis-cli SAVE >/dev/null; then
|
||||
warn "Could not force a Redis SAVE before archiving; continuing with the current volume files."
|
||||
fi
|
||||
|
||||
run_compose run --rm --no-deps -T --user root --entrypoint tar \
|
||||
-v "$ARCHIVE_DIR/volumes:/backup" \
|
||||
redis -czf /backup/redis-data.tar.gz -C /data .
|
||||
|
||||
info "Archiving docker-bundle deployment files."
|
||||
tar -czf "$ARCHIVE_DIR/deployment/docker-bundle-files.tar.gz" \
|
||||
-C "$(dirname "$ROOT_DIR")" \
|
||||
--exclude='docker-bundle/backups' \
|
||||
--exclude='docker-bundle/runtime' \
|
||||
"$(basename "$ROOT_DIR")"
|
||||
|
||||
if [ -d "$SOURCE_DIR" ]; then
|
||||
info "Archiving runtime/source checkout."
|
||||
tar -czf "$ARCHIVE_DIR/deployment/runtime-source.tar.gz" \
|
||||
-C "$(dirname "$SOURCE_DIR")" \
|
||||
--exclude='source/.env' \
|
||||
--exclude='source/node_modules' \
|
||||
--exclude='source/vendor' \
|
||||
--exclude='source/public/build' \
|
||||
--exclude='source/storage/app/private/recordings' \
|
||||
--exclude='source/storage/framework/cache' \
|
||||
--exclude='source/storage/framework/sessions' \
|
||||
--exclude='source/storage/framework/testing' \
|
||||
--exclude='source/storage/framework/views' \
|
||||
--exclude='source/storage/logs' \
|
||||
source
|
||||
else
|
||||
warn "$SOURCE_DIR was not found; the archive will rely on GIT_REPOSITORY_URL for source restoration."
|
||||
fi
|
||||
|
||||
info "Writing metadata."
|
||||
{
|
||||
printf 'created_at_utc=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf 'compose_project_name=%s\n' "$(env_value COMPOSE_PROJECT_NAME)"
|
||||
printf 'app_domain=%s\n' "$(env_value APP_DOMAIN)"
|
||||
printf 'hls_domain=%s\n' "$(env_value HLS_DOMAIN)"
|
||||
printf 'git_repository_url=%s\n' "$(env_value GIT_REPOSITORY_URL)"
|
||||
printf 'git_ref=%s\n' "$(env_value GIT_REF)"
|
||||
printf 'backup_name=%s\n' "$BACKUP_NAME"
|
||||
printf 'stop_writers=%s\n' "$STOP_WRITERS"
|
||||
} > "$ARCHIVE_DIR/metadata/manifest.env"
|
||||
|
||||
docker version > "$ARCHIVE_DIR/metadata/docker-version.txt" 2>&1 || true
|
||||
docker compose version > "$ARCHIVE_DIR/metadata/docker-compose-version.txt" 2>&1 || true
|
||||
run_compose ps > "$ARCHIVE_DIR/metadata/container-status.txt" 2>&1 || true
|
||||
|
||||
if [ -d "$SOURCE_DIR/.git" ]; then
|
||||
{
|
||||
git -C "$SOURCE_DIR" remote -v
|
||||
git -C "$SOURCE_DIR" rev-parse HEAD
|
||||
git -C "$SOURCE_DIR" status --short
|
||||
} > "$ARCHIVE_DIR/metadata/source-git.txt" 2>&1 || true
|
||||
fi
|
||||
|
||||
cat > "$ARCHIVE_DIR/README_RESTORE.md" <<'RESTORE_README'
|
||||
# Nyone docker-bundle Backup Restore
|
||||
|
||||
This archive contains everything managed by the `docker-bundle/` deployment bundle:
|
||||
|
||||
- deployment files and `.env`
|
||||
- `runtime/source` checkout when it existed at backup time
|
||||
- PostgreSQL dump
|
||||
- Laravel app storage volume
|
||||
- Redis data volume
|
||||
|
||||
Host-level files outside `docker-bundle/`, such as Nginx site files and Let's Encrypt certificates, are not included.
|
||||
|
||||
## Restore on a fresh server
|
||||
|
||||
Install Docker and the Compose plugin first. Then extract this zip and run:
|
||||
|
||||
```bash
|
||||
chmod +x restore.sh
|
||||
./restore.sh /opt/nyone/docker-bundle
|
||||
```
|
||||
|
||||
If the target already exists and you intentionally want to replace it:
|
||||
|
||||
```bash
|
||||
./restore.sh --replace /opt/nyone/docker-bundle
|
||||
```
|
||||
|
||||
The restore helper copies the deployment bundle, restores `runtime/source` when present, clones it from `.env` when missing, rebuilds the images, restores PostgreSQL, restores the app storage and Redis volumes, and starts the app services.
|
||||
|
||||
After restore, re-create host-level Nginx/TLS configuration if this is a new VPS.
|
||||
RESTORE_README
|
||||
|
||||
cat > "$ARCHIVE_DIR/restore.sh" <<'RESTORE_SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET_DIR="/opt/nyone/docker-bundle"
|
||||
REPLACE=false
|
||||
TMP_DIR=""
|
||||
|
||||
info() {
|
||||
printf '[nyone-restore] %s\n' "$*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[nyone-restore] ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
./restore.sh [--replace] [target-dir]
|
||||
|
||||
Example:
|
||||
./restore.sh /opt/nyone/docker-bundle
|
||||
./restore.sh --replace /opt/nyone/docker-bundle
|
||||
USAGE
|
||||
}
|
||||
|
||||
require_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "$1 is required."
|
||||
}
|
||||
|
||||
env_value() {
|
||||
local key="$1"
|
||||
local env_file="$TARGET_DIR/.env"
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
ensure_source_checkout() {
|
||||
local repository_url
|
||||
local git_ref
|
||||
|
||||
if [ -d "$TARGET_DIR/runtime/source" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
repository_url="$(env_value GIT_REPOSITORY_URL)"
|
||||
git_ref="$(env_value GIT_REF)"
|
||||
git_ref="${git_ref:-main}"
|
||||
|
||||
[ -n "$repository_url" ] || fail "runtime/source is missing and GIT_REPOSITORY_URL is not set in .env."
|
||||
|
||||
require_command git
|
||||
|
||||
info "Cloning runtime/source from GIT_REPOSITORY_URL."
|
||||
mkdir -p "$TARGET_DIR/runtime"
|
||||
git clone "$repository_url" "$TARGET_DIR/runtime/source"
|
||||
git -C "$TARGET_DIR/runtime/source" checkout "$git_ref"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
|
||||
rm -rf "$TMP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--replace)
|
||||
REPLACE=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
TARGET_DIR="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ "$TARGET_DIR" != "/" ] || fail "Refusing to restore into /."
|
||||
[ -f "$BACKUP_DIR/deployment/docker-bundle-files.tar.gz" ] || fail "deployment/docker-bundle-files.tar.gz is missing."
|
||||
[ -f "$BACKUP_DIR/database/postgres.sql.gz" ] || fail "database/postgres.sql.gz is missing."
|
||||
[ -f "$BACKUP_DIR/volumes/app-storage.tar.gz" ] || fail "volumes/app-storage.tar.gz is missing."
|
||||
|
||||
require_command docker
|
||||
require_command tar
|
||||
require_command gzip
|
||||
|
||||
TARGET_PARENT="$(dirname "$TARGET_DIR")"
|
||||
mkdir -p "$TARGET_PARENT"
|
||||
|
||||
if [ -e "$TARGET_DIR" ]; then
|
||||
if [ "$REPLACE" != true ]; then
|
||||
fail "$TARGET_DIR already exists. Use --replace if you want to replace it."
|
||||
fi
|
||||
|
||||
if [ -f "$TARGET_DIR/docker-compose.yml" ] && [ -f "$TARGET_DIR/.env" ]; then
|
||||
info "Stopping existing Compose stack."
|
||||
docker compose --env-file "$TARGET_DIR/.env" -f "$TARGET_DIR/docker-compose.yml" down || true
|
||||
fi
|
||||
|
||||
rm -rf "$TARGET_DIR"
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
info "Restoring docker-bundle files."
|
||||
tar -xzf "$BACKUP_DIR/deployment/docker-bundle-files.tar.gz" -C "$TMP_DIR"
|
||||
[ -d "$TMP_DIR/docker-bundle" ] || fail "Backup did not contain a docker-bundle directory."
|
||||
mv "$TMP_DIR/docker-bundle" "$TARGET_DIR"
|
||||
|
||||
if [ -f "$BACKUP_DIR/deployment/runtime-source.tar.gz" ]; then
|
||||
info "Restoring runtime/source."
|
||||
mkdir -p "$TARGET_DIR/runtime"
|
||||
tar -xzf "$BACKUP_DIR/deployment/runtime-source.tar.gz" -C "$TARGET_DIR/runtime"
|
||||
fi
|
||||
|
||||
chmod +x "$TARGET_DIR"/scripts/*.sh
|
||||
|
||||
cd "$TARGET_DIR"
|
||||
ensure_source_checkout
|
||||
|
||||
run_compose() {
|
||||
docker compose --env-file "$TARGET_DIR/.env" -f "$TARGET_DIR/docker-compose.yml" "$@"
|
||||
}
|
||||
|
||||
info "Validating Compose configuration."
|
||||
run_compose config >/dev/null
|
||||
|
||||
info "Building Docker images."
|
||||
run_compose build
|
||||
|
||||
info "Starting PostgreSQL."
|
||||
run_compose up -d db
|
||||
run_compose exec -T db sh -c 'until pg_isready -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"; do sleep 2; done'
|
||||
|
||||
info "Restoring PostgreSQL dump."
|
||||
gzip -dc "$BACKUP_DIR/database/postgres.sql.gz" \
|
||||
| run_compose exec -T db sh -c 'PGPASSWORD="$POSTGRES_PASSWORD" psql -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1'
|
||||
|
||||
info "Restoring app storage volume."
|
||||
run_compose run --rm --no-deps -T --user root --entrypoint sh \
|
||||
-v "$BACKUP_DIR/volumes:/backup:ro" \
|
||||
app -c 'find /var/www/html/storage -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar -xzf /backup/app-storage.tar.gz -C /var/www/html/storage'
|
||||
|
||||
if [ -f "$BACKUP_DIR/volumes/redis-data.tar.gz" ]; then
|
||||
info "Restoring Redis data volume."
|
||||
run_compose up -d redis
|
||||
run_compose stop redis >/dev/null || true
|
||||
run_compose run --rm --no-deps -T --user root --entrypoint sh \
|
||||
-v "$BACKUP_DIR/volumes:/backup:ro" \
|
||||
redis -c 'find /data -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar -xzf /backup/redis-data.tar.gz -C /data'
|
||||
fi
|
||||
|
||||
info "Starting application services."
|
||||
run_compose up -d --remove-orphans app mediamtx queue scheduler viewer-sync redis
|
||||
run_compose ps
|
||||
RESTORE_SCRIPT
|
||||
|
||||
chmod +x "$ARCHIVE_DIR/restore.sh"
|
||||
|
||||
info "Writing checksums."
|
||||
(
|
||||
cd "$ARCHIVE_DIR"
|
||||
find deployment database volumes metadata -type f -print0 | sort -z | xargs -0 sha256sum > checksums.sha256
|
||||
)
|
||||
|
||||
info "Creating zip archive."
|
||||
rm -f "$ZIP_PATH"
|
||||
(
|
||||
cd "$STAGE_DIR"
|
||||
zip -rq "$ZIP_PATH" "$BACKUP_NAME"
|
||||
)
|
||||
|
||||
info "Backup ready: $ZIP_PATH"
|
||||
184
docker-bundle/scripts/deploy.sh
Normal file
184
docker-bundle/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 app and HLS hostnames."
|
||||
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
|
||||
@@ -157,16 +157,16 @@ admin@example.com / password
|
||||
Current `.env` values relevant to streaming:
|
||||
|
||||
```env
|
||||
APP_URL=https://nyone.net
|
||||
STREAMING_RTMP_INGEST_URL=rtmp://nyone.net:19935
|
||||
APP_URL=https://nyone.app
|
||||
STREAMING_RTMP_INGEST_URL=rtmp://nyone.app:19935
|
||||
STREAMING_HLS_PUBLIC_URL=https://nyone-hls.net
|
||||
MEDIAMTX_SHARED_SECRET=change-this-secret
|
||||
```
|
||||
|
||||
MediaMTX current files:
|
||||
|
||||
- `deploy/mediamtx.yml`: currently points auth/hooks to `https://nyone.net`.
|
||||
- `deploy/mediamtx.home.yml`: points auth/hooks to `https://nyone.net`.
|
||||
- `deploy/mediamtx.yml`: currently points auth/hooks to `https://nyone.app`.
|
||||
- `deploy/mediamtx.home.yml`: points auth/hooks to `https://nyone.app`.
|
||||
|
||||
MediaMTX ports currently configured:
|
||||
|
||||
@@ -176,7 +176,7 @@ MediaMTX ports currently configured:
|
||||
OBS should use:
|
||||
|
||||
```text
|
||||
Server: rtmp://nyone.net:19935
|
||||
Server: rtmp://nyone.app:19935
|
||||
Stream Key: <channel-slug>?token=<generated-stream-key>
|
||||
```
|
||||
|
||||
|
||||
@@ -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 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