19 Commits

Author SHA1 Message Date
b3bb160b90 Splitting MediaRTX from Apache in docker-bundle
All checks were successful
tests / ci (8.5) (push) Successful in 4m0s
linter / quality (push) Successful in 1m16s
tests / ci (8.4) (push) Successful in 1m36s
2026-05-20 00:59:19 +03:30
cb62a0bae6 replace dockerized to docker-bundle 2026-05-19 20:25:15 +03:30
f302d605d2 added the backup flow. 2026-05-19 20:22:10 +03:30
cdb89d3287 fix running viewer-sync error on docker deploy 2026-05-19 19:44:01 +03:30
da19b6a8a2 fix storage link error on docker deploy 2026-05-19 19:26:57 +03:30
81dd742120 provide a valid cache path for docker deploy 2026-05-19 19:13:34 +03:30
34a683b614 Fixed the Docker build stage that runs npm run build 2026-05-19 18:58:14 +03:30
c06e237cd1 move npm run build runs with php artisan available 2026-05-19 18:18:00 +03:30
0fb7531e63 replace nyone.net to nyone.app 2026-05-19 15:23:38 +03:30
b3b7ebf8d7 add md files 2026-05-19 15:20:06 +03:30
f624150bd8 add md files 2026-05-19 15:12:34 +03:30
3229995dec Update the Docker defaults 2026-05-19 15:03:35 +03:30
4536ab77ee implement the Dockerized deployment bundle 2026-05-19 15:00:21 +03:30
f535530537 IRANSans is now referenced with relative URLs 2026-05-18 22:10:24 +03:30
b0417000e4 Removed the Welcome page header nav links 2026-05-18 22:02:12 +03:30
59e4ae60da Changed channel categories from DB-backed Category records to a backed PHP enum 2026-05-18 06:55:03 +03:30
295a408965 Fixed the failing test assertions 2026-05-18 05:55:34 +03:30
Meghdad
fc5a4a9b0b Merge pull request #1 from MeghdadFadaee/feature/localization
Feature/localization
2026-05-18 05:48:43 +03:30
Meghdad
eb4d950905 Create LICENSE 2026-05-17 04:54:27 +03:30
57 changed files with 2395 additions and 221 deletions

View File

@@ -2,7 +2,7 @@ APP_NAME=Nyone
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=https://nyone.net APP_URL=https://nyone.app
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@@ -64,7 +64,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" 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_HLS_PUBLIC_URL=https://nyone-hls.net
STREAMING_MEDIAMTX_API_URL=http://127.0.0.1:9997 STREAMING_MEDIAMTX_API_URL=http://127.0.0.1:9997
STREAMING_VIEWER_COUNT_CACHE_STORE=redis STREAMING_VIEWER_COUNT_CACHE_STORE=redis

21
LICENSE Normal file
View File

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

View File

@@ -35,8 +35,8 @@ The project config is aligned to these production-style domains:
```env ```env
APP_NAME=Nyone APP_NAME=Nyone
APP_URL=https://nyone.net APP_URL=https://nyone.app
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_HLS_PUBLIC_URL=https://nyone-hls.net
MEDIAMTX_SHARED_SECRET=change-this-secret MEDIAMTX_SHARED_SECRET=change-this-secret
``` ```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,7 +52,7 @@ return [
| |
*/ */
'url' => env('APP_URL', 'https://nyone.net'), 'url' => env('APP_URL', 'https://nyone.app'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@@ -41,7 +41,7 @@ return [
'public' => [ 'public' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('app/public'), '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', 'visibility' => 'public',
'throw' => false, 'throw' => false,
'report' => false, 'report' => false,

View File

@@ -46,7 +46,7 @@ return [
'username' => env('MAIL_USERNAME'), 'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'), 'password' => env('MAIL_PASSWORD'),
'timeout' => null, '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' => [ 'ses' => [

View File

@@ -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'), 'hls_public_url' => env('STREAMING_HLS_PUBLIC_URL', 'https://nyone-hls.net'),
/* /*

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Category; use App\Enums\ChannelCategory;
use App\Models\PlatformSetting; use App\Models\PlatformSetting;
use App\Models\User; use App\Models\User;
use App\Services\Streaming\StreamKeyManager; use App\Services\Streaming\StreamKeyManager;
@@ -15,20 +15,9 @@ class DatabaseSeeder extends Seeder
*/ */
public function run(): void 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(); PlatformSetting::current();
$admin = User::query()->firstOrNew(['email' => 'admin@nyone.net']); $admin = User::query()->firstOrNew(['email' => 'admin@nyone.app']);
$admin->forceFill([ $admin->forceFill([
'name' => 'Admin User', 'name' => 'Admin User',
'password' => 'password', 'password' => 'password',
@@ -37,7 +26,7 @@ class DatabaseSeeder extends Seeder
'can_create_channel' => false, 'can_create_channel' => false,
])->save(); ])->save();
$official = User::query()->firstOrNew(['email' => 'chief@nyone.net']); $official = User::query()->firstOrNew(['email' => 'chief@nyone.app']);
$official->forceFill([ $official->forceFill([
'name' => 'Chief Nyone', 'name' => 'Chief Nyone',
'password' => 'password', 'password' => 'password',
@@ -47,7 +36,7 @@ class DatabaseSeeder extends Seeder
])->save(); ])->save();
$channel = $official->channel()->updateOrCreate([], [ $channel = $official->channel()->updateOrCreate([], [
'category_id' => Category::query()->where('slug', 'creative')->value('id'), 'category' => ChannelCategory::Creative,
'slug' => 'nyone', 'slug' => 'nyone',
'display_name' => 'Nyone Official', 'display_name' => 'Nyone Official',
'description' => 'Official updates and live sessions from Nyone.', 'description' => 'Official updates and live sessions from Nyone.',

View File

@@ -1,5 +1,5 @@
authMethod: http 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: authHTTPExclude:
- action: read - action: read
- action: playback - action: playback
@@ -17,17 +17,17 @@ hls: yes
hlsAddress: :19988 hlsAddress: :19988
hlsVariant: mpegts hlsVariant: mpegts
hlsAllowOrigins: hlsAllowOrigins:
- https://nyone.net - https://nyone.app
pathDefaults: pathDefaults:
record: yes record: yes
recordPath: ./storage/app/private/recordings/%path/%Y-%m-%d_%H-%M-%S-%f 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 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" 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.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" 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: paths:
all_others: all_others:

View File

@@ -2,9 +2,9 @@
These configs match the current project domains: These configs match the current project domains:
- App: `https://nyone.net` - App: `https://nyone.app`
- HLS playback: `https://nyone-hls.net` - HLS playback: `https://nyone-hls.net`
- OBS ingest: `rtmp://nyone.net:19935` - OBS ingest: `rtmp://nyone.app:19935`
MediaMTX stays private on local ports: MediaMTX stays private on local ports:
@@ -29,14 +29,14 @@ sudo systemctl reload nginx
Make sure DNS points both hostnames to the server: Make sure DNS points both hostnames to the server:
- `nyone.net` - `nyone.app`
- `nyone-hls.net` - `nyone-hls.net`
The Laravel `.env` should use: The Laravel `.env` should use:
```env ```env
APP_URL=https://nyone.net APP_URL=https://nyone.app
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_HLS_PUBLIC_URL=https://nyone-hls.net
MEDIAMTX_SHARED_SECRET=change-this-secret MEDIAMTX_SHARED_SECRET=change-this-secret
``` ```

View File

@@ -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. # Copy to /etc/nginx/sites-available/nyone-app.conf and symlink into sites-enabled.
# Adjust root and fastcgi_pass for your server. # Adjust root and fastcgi_pass for your server.
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name nyone.net; server_name nyone.app;
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
@@ -13,13 +13,13 @@ server {
server { server {
listen 443 ssl http2; listen 443 ssl http2;
listen [::]:443 ssl http2; listen [::]:443 ssl http2;
server_name nyone.net; server_name nyone.app;
root /var/www/nyone/public; root /var/www/nyone/public;
index index.php; index index.php;
ssl_certificate /etc/letsencrypt/live/nyone.net/fullchain.pem; ssl_certificate /etc/letsencrypt/live/nyone.app/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nyone.net/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/nyone.app/privkey.pem;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;

View File

@@ -1,5 +1,5 @@
# HLS reverse proxy for MediaMTX. # 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 # https://nyone-hls.net/<channel-slug>/index.m3u8
server { server {

View File

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

View 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
View File

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

253
docker-bundle/README.md Normal file
View 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
View 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.

View 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

View 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:

View 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"]

View 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>

View 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

View File

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

View File

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

View File

@@ -0,0 +1,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."

View 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

View 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"

View File

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

View File

@@ -157,16 +157,16 @@ admin@example.com / password
Current `.env` values relevant to streaming: Current `.env` values relevant to streaming:
```env ```env
APP_URL=https://nyone.net APP_URL=https://nyone.app
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_HLS_PUBLIC_URL=https://nyone-hls.net
MEDIAMTX_SHARED_SECRET=change-this-secret MEDIAMTX_SHARED_SECRET=change-this-secret
``` ```
MediaMTX current files: MediaMTX current files:
- `deploy/mediamtx.yml`: currently 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.net`. - `deploy/mediamtx.home.yml`: points auth/hooks to `https://nyone.app`.
MediaMTX ports currently configured: MediaMTX ports currently configured:
@@ -176,7 +176,7 @@ MediaMTX ports currently configured:
OBS should use: OBS should use:
```text ```text
Server: rtmp://nyone.net:19935 Server: rtmp://nyone.app:19935
Stream Key: <channel-slug>?token=<generated-stream-key> Stream Key: <channel-slug>?token=<generated-stream-key>
``` ```

View File

@@ -82,6 +82,7 @@
"Create an account": "إنشاء حساب", "Create an account": "إنشاء حساب",
"Create channel": "إنشاء قناة", "Create channel": "إنشاء قناة",
"Create your channel": "أنشئ قناتك", "Create your channel": "أنشئ قناتك",
"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": "التعليم",
"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": "الألعاب",
"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": "الموسيقى",
"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": "برامج الحوار",
"The channel will be hidden from public browsing and watch pages.": "سيتم إخفاء القناة من التصفح العام وصفحات المشاهدة.", "The channel will be hidden from public browsing and watch pages.": "سيتم إخفاء القناة من التصفح العام وصفحات المشاهدة.",
"The channel will be visible again if the owning account is active.": "ستظهر القناة مرة أخرى إذا كان حساب المالك نشطًا.", "The channel will be visible again if the owning account is active.": "ستظهر القناة مرة أخرى إذا كان حساب المالك نشطًا.",
"The live floor is quiet": "ساحة البث هادئة", "The live floor is quiet": "ساحة البث هادئة",

View File

@@ -82,6 +82,7 @@
"Create an account": "Create an account", "Create an account": "Create an account",
"Create channel": "Create channel", "Create channel": "Create channel",
"Create your channel": "Create your channel", "Create your channel": "Create your channel",
"Creative": "Creative",
"Creator Studio": "Creator Studio", "Creator Studio": "Creator Studio",
"Creator access grants stay in the moderation console.": "Creator access grants stay in the moderation console.", "Creator access grants stay in the moderation console.": "Creator access grants stay in the moderation console.",
"Creator grants": "Creator grants", "Creator grants": "Creator grants",
@@ -101,6 +102,7 @@
"Don't have an account?": "Don't have an account?", "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 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", "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": "Email",
"Email address": "Email address", "Email address": "Email address",
"Email password reset link": "Email password reset link", "Email password reset link": "Email password reset link",
@@ -123,6 +125,7 @@
"Forgot password": "Forgot password", "Forgot password": "Forgot password",
"Forgot password?": "Forgot password?", "Forgot password?": "Forgot password?",
"Full name": "Full name", "Full name": "Full name",
"Gaming": "Gaming",
"Grant": "Grant", "Grant": "Grant",
"Hide password": "Hide password", "Hide password": "Hide password",
"Hide recovery codes": "Hide recovery codes", "Hide recovery codes": "Hide recovery codes",
@@ -152,6 +155,7 @@
"Message sent to admin.": "Message sent to admin.", "Message sent to admin.": "Message sent to admin.",
"Moderation console": "Moderation console", "Moderation console": "Moderation console",
"More": "More", "More": "More",
"Music": "Music",
"NEEDS APPROVAL": "NEEDS APPROVAL", "NEEDS APPROVAL": "NEEDS APPROVAL",
"NEEDS CHANNEL GRANT": "NEEDS CHANNEL GRANT", "NEEDS CHANNEL GRANT": "NEEDS CHANNEL GRANT",
"Name": "Name", "Name": "Name",
@@ -268,6 +272,7 @@
"Suspend": "Suspend", "Suspend": "Suspend",
"Suspend channel?": "Suspend channel?", "Suspend channel?": "Suspend channel?",
"System": "System", "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 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 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", "The live floor is quiet": "The live floor is quiet",

View File

@@ -82,6 +82,7 @@
"Create an account": "Crear una cuenta", "Create an account": "Crear una cuenta",
"Create channel": "Crear canal", "Create channel": "Crear canal",
"Create your channel": "Crea tu canal", "Create your channel": "Crea tu canal",
"Creative": "Creatividad",
"Creator Studio": "Estudio de creador", "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 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", "Creator grants": "Permisos de creador",
@@ -101,6 +102,7 @@
"Don't have an account?": "¿No tienes una cuenta?", "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 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", "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": "Correo electrónico",
"Email address": "Dirección de correo electrónico", "Email address": "Dirección de correo electrónico",
"Email password reset link": "Enviar enlace para restablecer contraseña", "Email password reset link": "Enviar enlace para restablecer contraseña",
@@ -123,6 +125,7 @@
"Forgot password": "Olvidé mi contraseña", "Forgot password": "Olvidé mi contraseña",
"Forgot password?": "¿Olvidaste tu contraseña?", "Forgot password?": "¿Olvidaste tu contraseña?",
"Full name": "Nombre completo", "Full name": "Nombre completo",
"Gaming": "Videojuegos",
"Grant": "Conceder", "Grant": "Conceder",
"Hide password": "Ocultar contraseña", "Hide password": "Ocultar contraseña",
"Hide recovery codes": "Ocultar códigos de recuperación", "Hide recovery codes": "Ocultar códigos de recuperación",
@@ -152,6 +155,7 @@
"Message sent to admin.": "Mensaje enviado al administrador.", "Message sent to admin.": "Mensaje enviado al administrador.",
"Moderation console": "Consola de moderación", "Moderation console": "Consola de moderación",
"More": "Más", "More": "Más",
"Music": "Música",
"NEEDS APPROVAL": "REQUIERE APROBACIÓN", "NEEDS APPROVAL": "REQUIERE APROBACIÓN",
"NEEDS CHANNEL GRANT": "REQUIERE PERMISO DE CANAL", "NEEDS CHANNEL GRANT": "REQUIERE PERMISO DE CANAL",
"Name": "Nombre", "Name": "Nombre",
@@ -268,6 +272,7 @@
"Suspend": "Suspender", "Suspend": "Suspender",
"Suspend channel?": "¿Suspender canal?", "Suspend channel?": "¿Suspender canal?",
"System": "Sistema", "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 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 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", "The live floor is quiet": "No hay actividad en vivo",

View File

@@ -82,6 +82,7 @@
"Create an account": "ایجاد یک حساب", "Create an account": "ایجاد یک حساب",
"Create channel": "ایجاد کانال", "Create channel": "ایجاد کانال",
"Create your channel": "کانال خود را ایجاد کنید", "Create your channel": "کانال خود را ایجاد کنید",
"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": "آموزش",
"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": "بازی",
"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": "موسیقی",
"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": "گفت‌وگو",
"The channel will be hidden from public browsing and watch pages.": "کانال از مرور عمومی و صفحات تماشا پنهان خواهد شد.", "The channel will be hidden from public browsing and watch pages.": "کانال از مرور عمومی و صفحات تماشا پنهان خواهد شد.",
"The channel will be visible again if the owning account is active.": "اگر حساب مالک فعال باشد، کانال دوباره قابل مشاهده خواهد شد.", "The channel will be visible again if the owning account is active.": "اگر حساب مالک فعال باشد، کانال دوباره قابل مشاهده خواهد شد.",
"The live floor is quiet": "فضای زنده آرام است", "The live floor is quiet": "فضای زنده آرام است",

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 237 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Enums\ChannelCategory;
use App\Models\Broadcast; use App\Models\Broadcast;
use App\Models\Channel; use App\Models\Channel;
use App\Models\User; 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 public function test_creator_dashboard_exposes_channel_display_media_props(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
@@ -101,7 +121,7 @@ class DashboardTest extends TestCase
'display_name' => $channel->display_name, 'display_name' => $channel->display_name,
'slug' => $channel->slug, 'slug' => $channel->slug,
'description' => $channel->description, 'description' => $channel->description,
'category_id' => $channel->category_id, 'category' => $channel->category?->value,
'thumbnail' => UploadedFile::fake()->image('thumbnail.jpg', 1280, 720), 'thumbnail' => UploadedFile::fake()->image('thumbnail.jpg', 1280, 720),
]) ])
->assertRedirect() ->assertRedirect()
@@ -131,7 +151,7 @@ class DashboardTest extends TestCase
'display_name' => $channel->display_name, 'display_name' => $channel->display_name,
'slug' => $channel->slug, 'slug' => $channel->slug,
'description' => $channel->description, 'description' => $channel->description,
'category_id' => $channel->category_id, 'category' => $channel->category?->value,
'thumbnail' => UploadedFile::fake()->create('thumbnail.txt', 8, 'text/plain'), 'thumbnail' => UploadedFile::fake()->create('thumbnail.txt', 8, 'text/plain'),
]) ])
->assertRedirect(route('dashboard')) ->assertRedirect(route('dashboard'))

View File

@@ -76,7 +76,7 @@ class SupportConversationTest extends TestCase
'category', 'category',
'subject', 'subject',
'body', '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], 'attachments' => [$attachment],
]) ])
->assertSessionHasErrors([ ->assertSessionHasErrors([
'attachments.0' => 'Attachments must be 200 MB or smaller.', 'attachments.0' => __('Attachments must be 200 MB or smaller.'),
]); ]);
} }