When NAS storage is enabled, uploaded files go directly to the NAS share
(users/{username}/videos/{title-slug}/) with no permanent local copy kept.
Thumbnails and video are fetched from NAS on demand for streaming/playback.
When NAS is disabled, files are organised into the same directory schema
in local storage.
- VideoController: branch upload flow on NAS enabled/disabled
- NasSyncService: add uploadDirectToNas() for direct NAS writes,
organizeLocalFiles() for local NAS-schema, localVideoDir() resolver,
deleteLocalAssets() for post-sync cleanup
- GenerateHlsJob: download from NAS via ensureLocalCopy() when local
file is absent (NAS-primary mode); clean up temp after HLS generation
- CompressVideoJob: place compressed file alongside original (any dir)
- Video/VideoSlide models: localVideoPath(), localThumbnailPath(),
thumbnailStorageKey(), localPath(), storageKey() helpers for
format-agnostic path resolution (old flat paths + new NAS-schema paths)
- MediaController: serve thumbnails from NAS-mirrored paths with NAS fallback
- SuperAdminController: use model path helpers for file deletion
- NasFreeLocalStorage: scan new users/ tree in addition to legacy flat dirs
- Settings: rename "NAS Storage Sync" tab to "NAS Storage", update description
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
662 lines
26 KiB
PHP
662 lines
26 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Setting;
|
|
use App\Models\User;
|
|
use App\Models\Video;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class NasSyncService
|
|
{
|
|
// ── Enable check ──────────────────────────────────────────────────────────
|
|
|
|
public function isEnabled(): bool
|
|
{
|
|
return Setting::get('nas_sync_enabled', 'false') === 'true'
|
|
&& ! empty($this->cfg()['host']);
|
|
}
|
|
|
|
// ── Slug helpers ──────────────────────────────────────────────────────────
|
|
|
|
public function userSlug(User $user): string
|
|
{
|
|
return $user->username ?: (string) $user->id;
|
|
}
|
|
|
|
public function titleSlug(string $title): string
|
|
{
|
|
$slug = mb_strtolower($title);
|
|
$slug = preg_replace('/[^a-z0-9]+/u', '-', $slug);
|
|
$slug = trim($slug, '-');
|
|
return $slug ?: 'video';
|
|
}
|
|
|
|
/**
|
|
* Return the NAS directory for a video.
|
|
*
|
|
* On first sync → pick a conflict-free slug and create the folder.
|
|
* On subsequent → find the existing folder by matching video ID in meta.json
|
|
* so renames of the title never lose the folder.
|
|
*/
|
|
public function resolveVideoDir(Video $video): string
|
|
{
|
|
$video->loadMissing('user');
|
|
$userSlug = $this->userSlug($video->user);
|
|
$base = "users/{$userSlug}/videos";
|
|
$titleSlug = $this->titleSlug($video->title);
|
|
|
|
// 1. Try the current title slug and numbered variants (-2, -3 …)
|
|
// If any of them already hold meta.json with our video ID → reuse it.
|
|
for ($serial = 1; $serial <= 50; $serial++) {
|
|
$candidate = $serial === 1 ? $titleSlug : "{$titleSlug}-{$serial}";
|
|
$meta = $this->readMeta("{$base}/{$candidate}");
|
|
|
|
if ($meta === null) {
|
|
// Folder is free — this is where we'll write
|
|
return "{$base}/{$candidate}";
|
|
}
|
|
|
|
if (($meta['id'] ?? null) === $video->id) {
|
|
// This folder already belongs to our video
|
|
return "{$base}/{$candidate}";
|
|
}
|
|
}
|
|
|
|
// 2. Fallback: scan all folders in the user's videos directory
|
|
$found = $this->scanForVideoDir($base, $video->id);
|
|
if ($found) return $found;
|
|
|
|
// 3. Last resort: use title slug + video ID suffix (guaranteed unique)
|
|
return "{$base}/{$titleSlug}-{$video->id}";
|
|
}
|
|
|
|
/**
|
|
* Read meta.json from a NAS directory. Returns decoded array or null.
|
|
*/
|
|
private function readMeta(string $dir): ?array
|
|
{
|
|
$content = $this->getContent("{$dir}/meta.json");
|
|
if ($content === null) return null;
|
|
$decoded = json_decode($content, true);
|
|
return is_array($decoded) ? $decoded : null;
|
|
}
|
|
|
|
/**
|
|
* List all subdirectories under $base and find the one whose
|
|
* meta.json carries the given video ID.
|
|
*/
|
|
private function scanForVideoDir(string $base, int $videoId): ?string
|
|
{
|
|
$cfg = $this->cfg();
|
|
$target = escapeshellarg($this->smbTarget($cfg));
|
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
|
|
|
$smbPath = str_replace('/', '\\', $base) . '\\*';
|
|
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg("ls \"{$smbPath}\"") . ' 2>&1', $output);
|
|
|
|
foreach ($output as $line) {
|
|
if (! preg_match('/^ (.+?)\s{2,}D\s/', $line, $m)) continue;
|
|
$name = trim($m[1]);
|
|
if ($name === '.' || $name === '..') continue;
|
|
|
|
$meta = $this->readMeta("{$base}/{$name}");
|
|
if (($meta['id'] ?? null) === $videoId) {
|
|
return "{$base}/{$name}";
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ── Local directory layout (mirrors NAS schema) ───────────────────────────
|
|
|
|
/**
|
|
* Absolute path to the local directory for a video, mirroring the NAS structure:
|
|
* storage/app/users/{username}/videos/{title-slug}/
|
|
*
|
|
* If the video is already organised (video->path starts with "users/"), the dir
|
|
* is derived directly from the stored path so title renames never relocate files.
|
|
* Otherwise a free slug-based slot is found (same logic as resolveVideoDir but local).
|
|
*/
|
|
public function localVideoDir(Video $video): string
|
|
{
|
|
// Already organised — derive from path
|
|
if (str_starts_with($video->path, 'users/')) {
|
|
return dirname(storage_path('app/' . $video->path));
|
|
}
|
|
|
|
$video->loadMissing('user');
|
|
$userSlug = $this->userSlug($video->user);
|
|
$base = storage_path("app/users/{$userSlug}/videos");
|
|
$titleSlug = $this->titleSlug($video->title);
|
|
|
|
for ($i = 1; $i <= 50; $i++) {
|
|
$candidate = $i === 1 ? $titleSlug : "{$titleSlug}-{$i}";
|
|
$dir = "{$base}/{$candidate}";
|
|
|
|
if (! is_dir($dir)) {
|
|
return $dir; // free slot
|
|
}
|
|
|
|
$metaFile = "{$dir}/meta.json";
|
|
if (file_exists($metaFile)) {
|
|
$meta = @json_decode(file_get_contents($metaFile), true);
|
|
if (is_array($meta) && ($meta['id'] ?? null) === $video->id) {
|
|
return $dir; // already belongs to this video
|
|
}
|
|
}
|
|
}
|
|
|
|
return "{$base}/{$titleSlug}-{$video->id}";
|
|
}
|
|
|
|
/**
|
|
* Move a freshly uploaded video's files into the NAS-mirrored local directory
|
|
* structure and update the DB record paths.
|
|
*
|
|
* Call this immediately after Video::create() in the upload flow.
|
|
* The method is idempotent: if the video is already organised it does nothing.
|
|
*/
|
|
public function organizeLocalFiles(Video $video): void
|
|
{
|
|
// Already organised
|
|
if (str_starts_with($video->path, 'users/')) return;
|
|
|
|
$video->loadMissing(['user', 'slides']);
|
|
|
|
$dir = $this->localVideoDir($video);
|
|
$fileSlug = $this->titleSlug($video->title);
|
|
|
|
@mkdir($dir, 0755, true);
|
|
|
|
$userSlug = $this->userSlug($video->user);
|
|
$relDir = 'users/' . $userSlug . '/videos/' . basename($dir);
|
|
$updates = [];
|
|
|
|
// ── Video file ───────────────────────────────────────────────────
|
|
$oldVideoPath = storage_path('app/' . $video->path);
|
|
if (file_exists($oldVideoPath)) {
|
|
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
|
|
$newFileName = "{$fileSlug}.{$ext}";
|
|
rename($oldVideoPath, "{$dir}/{$newFileName}");
|
|
$updates['path'] = "{$relDir}/{$newFileName}";
|
|
$updates['filename'] = $newFileName;
|
|
}
|
|
|
|
// ── Slides (process first; for audio, thumbnail IS slide 0) ──────
|
|
$firstSlideRelPath = null;
|
|
if ($video->slides->isNotEmpty()) {
|
|
@mkdir("{$dir}/slides", 0755, true);
|
|
foreach ($video->slides->sortBy('position') as $slide) {
|
|
$oldSlidePath = storage_path('app/public/thumbnails/' . $slide->filename);
|
|
if (! file_exists($oldSlidePath)) continue;
|
|
$ext = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
|
|
$newSlideName = "{$slide->id}.{$ext}";
|
|
rename($oldSlidePath, "{$dir}/slides/{$newSlideName}");
|
|
$newSlideFilename = "{$relDir}/slides/{$newSlideName}";
|
|
$slide->update(['filename' => $newSlideFilename]);
|
|
if ($firstSlideRelPath === null) {
|
|
$firstSlideRelPath = $newSlideFilename;
|
|
}
|
|
}
|
|
// For audio uploads the thumbnail is the first slide
|
|
if ($firstSlideRelPath !== null) {
|
|
$updates['thumbnail'] = $firstSlideRelPath;
|
|
}
|
|
}
|
|
|
|
// ── Standalone thumbnail (video uploads, no slides) ──────────────
|
|
if ($video->thumbnail && ! isset($updates['thumbnail'])) {
|
|
$oldThumbPath = storage_path('app/public/thumbnails/' . $video->thumbnail);
|
|
if (file_exists($oldThumbPath)) {
|
|
$ext = pathinfo($video->thumbnail, PATHINFO_EXTENSION) ?: 'jpg';
|
|
$newThumbName = "thumb.{$ext}";
|
|
rename($oldThumbPath, "{$dir}/{$newThumbName}");
|
|
$updates['thumbnail'] = "{$relDir}/{$newThumbName}";
|
|
}
|
|
}
|
|
|
|
// ── meta.json (enables localVideoDir to identify this dir later) ─
|
|
$this->writeLocalMeta($video, $dir);
|
|
|
|
if (! empty($updates)) {
|
|
$video->update($updates);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write a meta.json to the local video directory so localVideoDir() can
|
|
* resolve the folder even after a title rename.
|
|
*/
|
|
public function writeLocalMeta(Video $video, string $dir): void
|
|
{
|
|
$meta = json_encode([
|
|
'id' => $video->id,
|
|
'user_id' => $video->user_id,
|
|
'title' => $video->title,
|
|
'created_at' => $video->created_at?->toIso8601String(),
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
|
|
@file_put_contents("{$dir}/meta.json", $meta);
|
|
}
|
|
|
|
// ── Local-cache helpers (used when NAS is the primary storage) ───────────
|
|
|
|
/**
|
|
* Absolute path where a NAS video is cached locally for HTTP streaming.
|
|
* Separate from public storage so it's never accidentally served statically.
|
|
*/
|
|
public function localCachePath(Video $video): string
|
|
{
|
|
return storage_path('app/nas_cache/videos/' . $video->filename);
|
|
}
|
|
|
|
/**
|
|
* Ensure the video file is available at a local path for streaming.
|
|
*
|
|
* Priority:
|
|
* 1. Regular local storage (video uploaded while NAS was off, or not yet cleaned)
|
|
* 2. NAS stream cache (previously downloaded copy)
|
|
* 3. Download from NAS (first-time stream after NAS-primary upload)
|
|
*
|
|
* Returns the local absolute path, or null if unavailable.
|
|
*/
|
|
public function ensureLocalCopy(Video $video): ?string
|
|
{
|
|
// 1. Regular local storage
|
|
$regularPath = storage_path('app/' . $video->path);
|
|
if (file_exists($regularPath)) return $regularPath;
|
|
|
|
// 2. Existing stream cache
|
|
$cachePath = $this->localCachePath($video);
|
|
if (file_exists($cachePath)) return $cachePath;
|
|
|
|
// 3. Download from NAS
|
|
if (! $this->isEnabled()) return null;
|
|
|
|
$cacheDir = dirname($cachePath);
|
|
if (! is_dir($cacheDir)) mkdir($cacheDir, 0755, true);
|
|
|
|
$nasDir = $this->resolveVideoDir($video);
|
|
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
|
|
$fileSlug = $this->titleSlug($video->title);
|
|
$nasFile = "{$nasDir}/{$fileSlug}.{$ext}";
|
|
|
|
$cfg = $this->cfg();
|
|
$target = escapeshellarg($this->smbTarget($cfg));
|
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
|
|
|
exec('smbclient ' . $target . ' -U ' . $cred
|
|
. ' -c ' . escapeshellarg('get "' . $nasFile . '" "' . $cachePath . '"')
|
|
. ' 2>&1', $out, $code);
|
|
|
|
if ($code !== 0 || ! file_exists($cachePath)) {
|
|
@unlink($cachePath);
|
|
Log::warning('NAS: failed to cache video for streaming', [
|
|
'video_id' => $video->id,
|
|
'nas_path' => $nasFile,
|
|
'output' => implode(' ', $out),
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
Log::info('NAS: video cached locally for streaming', ['video_id' => $video->id]);
|
|
return $cachePath;
|
|
}
|
|
|
|
/**
|
|
* Ensure a local asset file exists, downloading it from the NAS if missing.
|
|
* Used by MediaController to serve thumbnails, avatars, and banners with a NAS fallback.
|
|
*
|
|
* Returns true if the file is available locally after the call.
|
|
*/
|
|
public function ensureLocalAsset(string $localPath, string $nasPath): bool
|
|
{
|
|
if (file_exists($localPath)) return true;
|
|
|
|
$dir = dirname($localPath);
|
|
if (! is_dir($dir)) @mkdir($dir, 0755, true);
|
|
|
|
$cfg = $this->cfg();
|
|
$target = escapeshellarg($this->smbTarget($cfg));
|
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
|
|
|
exec('smbclient ' . $target . ' -U ' . $cred
|
|
. ' -c ' . escapeshellarg('get "' . $nasPath . '" "' . $localPath . '"')
|
|
. ' 2>&1', $out, $code);
|
|
|
|
if ($code !== 0 || ! file_exists($localPath)) {
|
|
@unlink($localPath);
|
|
Log::warning('NAS: failed to fetch asset', [
|
|
'nas_path' => $nasPath,
|
|
'output' => implode(' ', $out),
|
|
]);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Delete the local video file after it has been successfully pushed to NAS.
|
|
*/
|
|
public function deleteLocalVideo(Video $video): void
|
|
{
|
|
$path = storage_path('app/' . $video->path);
|
|
if (file_exists($path)) {
|
|
@unlink($path);
|
|
Log::info('NAS: local video removed after NAS push', ['video_id' => $video->id]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete local thumbnail and slide images after they have been pushed to NAS.
|
|
* MediaController will re-fetch them from NAS on demand via ensureLocalAsset().
|
|
*/
|
|
public function deleteLocalAssets(Video $video): void
|
|
{
|
|
$thumb = $video->localThumbnailPath();
|
|
if ($thumb && file_exists($thumb)) {
|
|
@unlink($thumb);
|
|
}
|
|
|
|
$video->loadMissing('slides');
|
|
foreach ($video->slides as $slide) {
|
|
$path = $slide->localPath();
|
|
if (file_exists($path)) {
|
|
@unlink($path);
|
|
}
|
|
}
|
|
|
|
Log::info('NAS: local assets removed after NAS push', ['video_id' => $video->id]);
|
|
}
|
|
|
|
// ── Direct NAS upload (NAS-primary mode) ──────────────────────────────────
|
|
|
|
/**
|
|
* Push a freshly uploaded video directly to the NAS without keeping a local copy.
|
|
*
|
|
* Called from VideoController::store() when NAS is enabled.
|
|
* Updates video->path, video->filename, video->thumbnail, and status in the DB.
|
|
*
|
|
* @param Video $video
|
|
* @param string $tempVideoPath Absolute path to the temporary local video file
|
|
* @param string|null $tempThumbPath Absolute path to the temporary thumbnail (may be null)
|
|
* @param array $slideAbsPaths [ position => absPath ] for audio slides
|
|
*/
|
|
public function uploadDirectToNas(
|
|
Video $video,
|
|
string $tempVideoPath,
|
|
?string $tempThumbPath,
|
|
array $slideAbsPaths = []
|
|
): void {
|
|
$video->loadMissing(['user', 'slides']);
|
|
|
|
$dir = $this->resolveVideoDir($video); // NAS-relative, e.g. users/john/videos/my-video
|
|
$fileSlug = $this->titleSlug($video->title);
|
|
|
|
$this->mkdirp($dir);
|
|
|
|
$updates = [];
|
|
|
|
// ── Video file ───────────────────────────────────────────────────
|
|
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
|
|
if (file_exists($tempVideoPath)) {
|
|
$this->putFile($tempVideoPath, "{$dir}/{$fileSlug}.{$ext}");
|
|
@unlink($tempVideoPath);
|
|
$updates['path'] = "{$dir}/{$fileSlug}.{$ext}";
|
|
$updates['filename'] = "{$fileSlug}.{$ext}";
|
|
}
|
|
|
|
// ── Slides (audio uploads — thumbnail IS the first slide) ────────
|
|
$firstSlideNasPath = null;
|
|
if (! empty($slideAbsPaths)) {
|
|
$this->mkdirp("{$dir}/slides");
|
|
foreach ($video->slides->sortBy('position') as $slide) {
|
|
$absPath = $slideAbsPaths[$slide->position] ?? null;
|
|
if (! $absPath || ! file_exists($absPath)) continue;
|
|
$slideExt = pathinfo($absPath, PATHINFO_EXTENSION) ?: 'jpg';
|
|
$nasSlideFile = "{$dir}/slides/{$slide->position}.{$slideExt}";
|
|
$this->putFile($absPath, $nasSlideFile);
|
|
@unlink($absPath);
|
|
$slideRelPath = "{$dir}/slides/{$slide->position}.{$slideExt}";
|
|
$slide->update(['filename' => $slideRelPath]);
|
|
if ($firstSlideNasPath === null) {
|
|
$firstSlideNasPath = $slideRelPath;
|
|
}
|
|
}
|
|
if ($firstSlideNasPath !== null) {
|
|
$updates['thumbnail'] = $firstSlideNasPath;
|
|
}
|
|
}
|
|
|
|
// ── Standalone thumbnail (video uploads without slides) ──────────
|
|
if ($tempThumbPath && file_exists($tempThumbPath) && $firstSlideNasPath === null) {
|
|
$this->putFile($tempThumbPath, "{$dir}/thumb.webp");
|
|
@unlink($tempThumbPath);
|
|
$updates['thumbnail'] = "{$dir}/thumb.webp";
|
|
}
|
|
|
|
// ── meta.json ────────────────────────────────────────────────────
|
|
$this->putContent(json_encode([
|
|
'id' => $video->id,
|
|
'user_id' => $video->user_id,
|
|
'title' => $video->title,
|
|
'created_at' => $video->created_at?->toIso8601String(),
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$dir}/meta.json");
|
|
|
|
// File is now on NAS and accessible — mark as ready
|
|
$updates['status'] = 'ready';
|
|
|
|
Log::info('NAS: direct upload complete', ['video_id' => $video->id, 'dir' => $dir]);
|
|
|
|
$video->update($updates);
|
|
}
|
|
|
|
// ── High-level sync methods ───────────────────────────────────────────────
|
|
|
|
public function syncVideo(Video $video): void
|
|
{
|
|
$video->loadMissing('user');
|
|
$dir = $this->resolveVideoDir($video);
|
|
$fileSlug = $this->titleSlug($video->title);
|
|
$this->mkdirp($dir);
|
|
|
|
// {title-slug}.{ext}
|
|
$localVideo = storage_path('app/' . $video->path);
|
|
if (file_exists($localVideo)) {
|
|
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
|
|
$this->putFile($localVideo, "{$dir}/{$fileSlug}.{$ext}");
|
|
}
|
|
|
|
// thumb.webp
|
|
$localThumb = $video->localThumbnailPath();
|
|
if ($localThumb && file_exists($localThumb)) {
|
|
$this->putFile($localThumb, "{$dir}/thumb.webp");
|
|
}
|
|
|
|
// slides/{position}.{ext}
|
|
$video->loadMissing('slides');
|
|
if ($video->slides->isNotEmpty()) {
|
|
$this->mkdirp("{$dir}/slides");
|
|
foreach ($video->slides as $slide) {
|
|
$localSlide = $slide->localPath();
|
|
if (file_exists($localSlide)) {
|
|
$ext = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
|
|
$this->putFile($localSlide, "{$dir}/slides/{$slide->position}.{$ext}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// meta.json (always written last so readMeta can find the folder)
|
|
$this->putContent(json_encode([
|
|
'id' => $video->id,
|
|
'user_id' => $video->user_id,
|
|
'title' => $video->title,
|
|
'description' => $video->description,
|
|
'type' => $video->type,
|
|
'visibility' => $video->visibility,
|
|
'status' => $video->status,
|
|
'duration' => $video->duration,
|
|
'width' => $video->width,
|
|
'height' => $video->height,
|
|
'orientation' => $video->orientation,
|
|
'mime_type' => $video->mime_type,
|
|
'size' => $video->size,
|
|
'created_at' => $video->created_at?->toIso8601String(),
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$dir}/meta.json");
|
|
}
|
|
|
|
public function syncVideoMeta(Video $video): void
|
|
{
|
|
$video->loadMissing('user');
|
|
$dir = $this->resolveVideoDir($video);
|
|
$this->mkdirp($dir);
|
|
|
|
$this->putContent(json_encode([
|
|
'id' => $video->id,
|
|
'user_id' => $video->user_id,
|
|
'title' => $video->title,
|
|
'description' => $video->description,
|
|
'type' => $video->type,
|
|
'visibility' => $video->visibility,
|
|
'status' => $video->status,
|
|
'duration' => $video->duration,
|
|
'width' => $video->width,
|
|
'height' => $video->height,
|
|
'orientation' => $video->orientation,
|
|
'mime_type' => $video->mime_type,
|
|
'size' => $video->size,
|
|
'updated_at' => $video->updated_at?->toIso8601String(),
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$dir}/meta.json");
|
|
}
|
|
|
|
public function deleteVideo(Video $video): void
|
|
{
|
|
$video->loadMissing('user');
|
|
$dir = $this->resolveVideoDir($video);
|
|
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
|
|
|
|
$this->deleteFile("{$dir}/" . $this->titleSlug($video->title) . ".{$ext}");
|
|
$this->deleteFile("{$dir}/thumb.webp");
|
|
$this->deleteFile("{$dir}/meta.json");
|
|
$this->deleteFile("{$dir}/view-log.json");
|
|
$this->deleteFile("{$dir}/edit-log.json");
|
|
$this->deleteFolder($dir);
|
|
}
|
|
|
|
public function syncAvatar(User $user, string $localAbsPath): void
|
|
{
|
|
$dir = "users/{$this->userSlug($user)}/profile";
|
|
$this->mkdirp($dir);
|
|
$this->putFile($localAbsPath, "{$dir}/avatar.webp");
|
|
}
|
|
|
|
public function syncCover(User $user, string $localAbsPath): void
|
|
{
|
|
$dir = "users/{$this->userSlug($user)}/profile";
|
|
$this->mkdirp($dir);
|
|
$this->putFile($localAbsPath, "{$dir}/cover.webp");
|
|
}
|
|
|
|
// ── SMB primitives ────────────────────────────────────────────────────────
|
|
|
|
public function mkdirp(string $path): void
|
|
{
|
|
$cfg = $this->cfg();
|
|
$target = escapeshellarg($this->smbTarget($cfg));
|
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
|
|
|
$parts = array_values(array_filter(explode('/', str_replace('\\', '/', $path))));
|
|
$cmds = [];
|
|
$built = '';
|
|
foreach ($parts as $part) {
|
|
$built = $built ? "{$built}/{$part}" : $part;
|
|
$cmds[] = 'mkdir "' . $built . '"';
|
|
}
|
|
|
|
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg(implode('; ', $cmds)) . ' 2>&1');
|
|
// Intentionally ignore exit code — NT_STATUS_OBJECT_NAME_COLLISION means dir exists
|
|
}
|
|
|
|
public function putFile(string $localAbsPath, string $nasRelPath): bool
|
|
{
|
|
$cfg = $this->cfg();
|
|
$target = escapeshellarg($this->smbTarget($cfg));
|
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
|
$cmd = 'put "' . $localAbsPath . '" "' . $nasRelPath . '"';
|
|
|
|
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg($cmd) . ' 2>&1', $output, $code);
|
|
|
|
if ($code !== 0) {
|
|
Log::warning('NAS putFile failed [' . $nasRelPath . ']: ' . implode(' ', $output));
|
|
}
|
|
|
|
return $code === 0;
|
|
}
|
|
|
|
public function putContent(string $content, string $nasRelPath): bool
|
|
{
|
|
$tmp = tempnam(sys_get_temp_dir(), 'nassync_');
|
|
file_put_contents($tmp, $content);
|
|
$ok = $this->putFile($tmp, $nasRelPath);
|
|
@unlink($tmp);
|
|
return $ok;
|
|
}
|
|
|
|
public function getContent(string $nasRelPath): ?string
|
|
{
|
|
$cfg = $this->cfg();
|
|
$target = escapeshellarg($this->smbTarget($cfg));
|
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
|
$tmp = tempnam(sys_get_temp_dir(), 'nasget_');
|
|
$cmd = 'get "' . $nasRelPath . '" "' . $tmp . '"';
|
|
|
|
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg($cmd) . ' 2>&1', $output, $code);
|
|
|
|
if ($code !== 0 || ! file_exists($tmp)) {
|
|
@unlink($tmp);
|
|
return null;
|
|
}
|
|
|
|
$content = file_get_contents($tmp);
|
|
@unlink($tmp);
|
|
return $content !== false ? $content : null;
|
|
}
|
|
|
|
public function deleteFile(string $nasRelPath): void
|
|
{
|
|
$cfg = $this->cfg();
|
|
$target = escapeshellarg($this->smbTarget($cfg));
|
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
|
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg('rm "' . $nasRelPath . '"') . ' 2>&1');
|
|
}
|
|
|
|
public function deleteFolder(string $nasRelPath): void
|
|
{
|
|
$cfg = $this->cfg();
|
|
$target = escapeshellarg($this->smbTarget($cfg));
|
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
|
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg('rmdir "' . $nasRelPath . '"') . ' 2>&1');
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
private function cfg(): array
|
|
{
|
|
return app(\P7H\NasFileManager\NasStorageService::class)->cfg();
|
|
}
|
|
|
|
private function smbTarget(array $cfg): string
|
|
{
|
|
return '//' . $cfg['host'] . '/' . trim($cfg['smb_share'] ?? '', '/');
|
|
}
|
|
|
|
private function smbCredential(array $cfg): string
|
|
{
|
|
$domain = ! empty($cfg['smb_domain']) ? $cfg['smb_domain'] . '/' : '';
|
|
return $domain . ($cfg['username'] ?? '') . '%' . ($cfg['password'] ?? '');
|
|
}
|
|
}
|