diff --git a/.github/workflows/buildrelease.yml b/.github/workflows/buildrelease.yml index ee44526..9ae6aab 100644 --- a/.github/workflows/buildrelease.yml +++ b/.github/workflows/buildrelease.yml @@ -3,9 +3,14 @@ name: Build and Release # Triggers: # - Release published with tags: # - server-v* : Build only Server -# - app-v* : Build only TFMAudioApp (Android, Windows, macOS) -# - v* : Build everything -# - Manual workflow dispatch with checkboxes +# - app-v* : Build only TFMAudioApp (Android, Windows, macOS) β€” on demand +# - v* : Build only Server (mobile apps are NOT built by default) +# - Manual workflow dispatch with checkboxes (build any target on demand) +# +# NOTE: Mobile apps (Android/Windows/macOS) are intentionally NOT built for a +# plain "v*" release. Build them on demand when they actually change, either +# with an "app-v*" release tag or via the manual workflow_dispatch checkboxes. +# See docs/releases.md for the full policy. on: release: @@ -128,10 +133,11 @@ jobs: build-android: name: Build Android APK runs-on: ubuntu-latest - # Run if: manual with build_android OR release with app-v* or v* (but not server-v*) + # On demand only: manual with build_android OR release with an app-v* tag. + # A plain v* release does NOT build the mobile apps. if: | (github.event_name == 'workflow_dispatch' && inputs.build_android) || - (github.event_name == 'release' && (startsWith(github.event.release.tag_name, 'app-v') || (startsWith(github.event.release.tag_name, 'v') && !startsWith(github.event.release.tag_name, 'server-v')))) + (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'app-v')) steps: - name: 'πŸ“„ Checkout' uses: actions/checkout@v4 @@ -317,10 +323,11 @@ jobs: build-windows: name: Build Windows App runs-on: windows-2022 - # Run if: manual with build_windows OR release with app-v* or v* (but not server-v*) + # On demand only: manual with build_windows OR release with an app-v* tag. + # A plain v* release does NOT build the mobile apps. if: | (github.event_name == 'workflow_dispatch' && inputs.build_windows) || - (github.event_name == 'release' && (startsWith(github.event.release.tag_name, 'app-v') || (startsWith(github.event.release.tag_name, 'v') && !startsWith(github.event.release.tag_name, 'server-v')))) + (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'app-v')) steps: - name: 'πŸ“„ Checkout' uses: actions/checkout@v4 @@ -449,10 +456,11 @@ jobs: build-macos: name: Build macOS App runs-on: macos-15 - # Run if: manual with build_macos OR release with app-v* or v* (but not server-v*) + # On demand only: manual with build_macos OR release with an app-v* tag. + # A plain v* release does NOT build the mobile apps. if: | (github.event_name == 'workflow_dispatch' && inputs.build_macos) || - (github.event_name == 'release' && (startsWith(github.event.release.tag_name, 'app-v') || (startsWith(github.event.release.tag_name, 'v') && !startsWith(github.event.release.tag_name, 'server-v')))) + (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'app-v')) steps: - name: 'πŸ“„ Checkout' uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 61b4475..7f35d58 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ TelegramDownloader/userData.json TelegramDownloader/.claude/ .claude/ +plans/ diff --git a/README.md b/README.md index 9d268c0..25f1d6a 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,46 @@ services: - When you want, you can download the files again to a location on your local computer, selecting the files or folders and clicking the `Download to Local` button. +## Android apps + +Both apps are native Kotlin + Jetpack Compose clients built on the server's REST API v1 +(`/api/v1`), documented in [`docs/api`](docs/api). Point them at your server address and +API key and they work over the same Telegram session as the web. + +### Phone and tablet β€” [tfm-android-app](https://github.com/mateof/tfm-android-app) + +Full file manager on the go: + +- Telegram login from the app, by **QR** or phone number (with 2FA). +- **Channels**: saved / all / favourites, search, statistics, create a channel, join by + invitation, leave, and build or refresh a channel index choosing which media types to + scan. +- **File browser** with breadcrumbs, filters, recursive search, multi-select, folder + creation, rename, copy/move, delete, **upload from the device** and download to the + server or to the device. +- **Server local storage**: browse, upload, send to a Telegram channel without + re-uploading the bytes, and clear the streaming cache. +- **Live transfers** over the `/hubs/transfers` SignalR hub: speed, progress and queues, + with global and per-item pause, resume, cancel and retry. +- **Background audio player** (Media3 + MediaSession) with a mini player and server-side + playlists, plus **video streaming** for channel and local files. +- Server settings: simultaneous downloads, parallel chunks, connections per download. + +### Android TV and Fire TV β€” [tfm-android-tv-app](https://github.com/mateof/tfm-android-tv-app) + +A D-pad first client focused on watching the videos you keep in your channels: + +- Channels split into mine, shared, favourites, Telegram chat folders and all, with a + name search. +- Folder navigation inside a channel, an **all videos** view and a **messages** view, + each sortable by name, date or size. +- Playback with the built-in player (ExoPlayer plus FFmpeg software decoders, so MKV or + AVI with AC3/DTS play fine), VLC, any other installed player or the system default. +- Updates itself from GitHub Releases, and runs on Android 6.0, which covers Fire TV + sticks from 2015 onwards. + +Both repositories publish a signed APK as a GitHub Release on every push to `main`. + ## Music player Music player diff --git a/TelegramDownloader/Configuration/config.example.json b/TelegramDownloader/Configuration/config.example.json index 0cb1547..3728b04 100644 --- a/TelegramDownloader/Configuration/config.example.json +++ b/TelegramDownloader/Configuration/config.example.json @@ -5,5 +5,7 @@ "mongo_connection_string": "", "avoid_checking_certificate": false, "open_browser_on_startup": true, - "mobile_api_key": "my_api_key" + "mobile_api_key": "my_api_key", + "webdav_user": "", + "webdav_password": "" } \ No newline at end of file diff --git a/TelegramDownloader/Controllers/Api/V1/ApiV1ControllerBase.cs b/TelegramDownloader/Controllers/Api/V1/ApiV1ControllerBase.cs new file mode 100644 index 0000000..7a19caa --- /dev/null +++ b/TelegramDownloader/Controllers/Api/V1/ApiV1ControllerBase.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Mvc; +using TelegramDownloader.Models.Api; + +namespace TelegramDownloader.Controllers.Api.V1 +{ + /// + /// Shared plumbing for every v1 controller: consistent envelopes, consistent + /// status codes and a helper to build absolute URLs behind a reverse proxy. + /// + [ApiController] + [Produces("application/json")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status500InternalServerError)] + public abstract class ApiV1ControllerBase : ControllerBase + { + /// + /// Absolute base URL of this server as seen by the client, honouring + /// X-Forwarded-Proto/X-Forwarded-Host (the app enables + /// forwarded headers at startup). + /// + protected string BaseUrl => $"{Request.Scheme}://{Request.Host}"; + + protected IActionResult OkResult(T data, string? message = null) => + Ok(ApiResult.Ok(data, message)); + + protected IActionResult OkPaged(T data, PageInfo page) => + Ok(ApiResult.Ok(data, page)); + + protected IActionResult OkEmpty(string? message = null) => + Ok(ApiResult.Done(message)); + + protected IActionResult BadRequestResult(string message, string code = ApiErrorCodes.InvalidRequest, string? detail = null) => + BadRequest(ApiResult.Fail(code, message, detail)); + + protected IActionResult NotFoundResult(string message, string code = ApiErrorCodes.NotFound) => + NotFound(ApiResult.Fail(code, message)); + + protected IActionResult ConflictResult(string message, string code = ApiErrorCodes.Conflict) => + Conflict(ApiResult.Fail(code, message)); + + protected IActionResult ForbiddenResult(string message) => + StatusCode(StatusCodes.Status403Forbidden, ApiResult.Fail(ApiErrorCodes.Forbidden, message)); + + protected IActionResult ErrorResult(string message, Exception? ex = null, string code = ApiErrorCodes.InternalError) => + StatusCode(StatusCodes.Status500InternalServerError, ApiResult.Fail(code, message, ex?.Message)); + + protected IActionResult UnavailableResult(string message, string code = ApiErrorCodes.ServiceUnavailable) => + StatusCode(StatusCodes.Status503ServiceUnavailable, ApiResult.Fail(code, message)); + + /// + /// Applies in-memory paging to an already materialised list and returns + /// both the page and its metadata. + /// + protected static (List Items, PageInfo Page) Paginate(IReadOnlyList source, PagedQuery query) + { + var page = PageInfo.Create(query.Page, query.PageSize, source.Count); + var items = source.Skip((query.Page - 1) * query.PageSize).Take(query.PageSize).ToList(); + return (items, page); + } + } +} diff --git a/TelegramDownloader/Controllers/Api/V1/AuthController.cs b/TelegramDownloader/Controllers/Api/V1/AuthController.cs new file mode 100644 index 0000000..02ad0c8 --- /dev/null +++ b/TelegramDownloader/Controllers/Api/V1/AuthController.cs @@ -0,0 +1,260 @@ +using Microsoft.AspNetCore.Mvc; +using TelegramDownloader.Data; +using TelegramDownloader.Models.Api; +using TelegramDownloader.Services; +using TelegramDownloader.Services.Api; + +namespace TelegramDownloader.Controllers.Api.V1 +{ + /// + /// Telegram session lifecycle: sign in with a phone number or a QR code, + /// inspect the current session and sign out. + /// + /// The Telegram session lives on the server and is shared by the web UI and + /// every API client: signing in here also signs in the web UI, and signing + /// out terminates both. + /// + [Route("api/v1/auth")] + [Tags("Auth")] + public class AuthController : ApiV1ControllerBase + { + private readonly ITelegramService _telegram; + private readonly ISetupService _setup; + private readonly QrLoginSessionManager _qr; + private readonly ILogger _logger; + + public AuthController( + ITelegramService telegram, + ISetupService setup, + QrLoginSessionManager qr, + ILogger logger) + { + _telegram = telegram; + _setup = setup; + _qr = qr; + _logger = logger; + } + + /// Current authentication state. + /// + /// Call this first. Step tells you what the server expects next: + /// phone, vc (verification code), pass (2FA + /// password), ok (already signed in) or setup_required + /// when the application has not been configured yet. + /// + [HttpGet("status")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task Status() + { + try + { + var dto = new AuthStatusDto { IsConfigured = _telegram.IsConfigured }; + + if (!_telegram.IsConfigured) + { + try + { + _telegram.InitializeClient(); + dto.IsConfigured = _telegram.IsConfigured; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Telegram client could not be initialized"); + } + } + + if (!dto.IsConfigured) + { + dto.Step = AuthStep.SetupRequired; + return OkResult(dto); + } + + dto.Step = await _telegram.checkAuth(null) ?? AuthStep.Phone; + dto.IsAuthenticated = dto.Step == AuthStep.Authenticated; + + if (dto.IsAuthenticated) + dto.User = await BuildUserAsync(); + + return OkResult(dto); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading auth status"); + return ErrorResult("Could not read the authentication status", ex); + } + } + + /// Signed-in Telegram user. + [HttpGet("me")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status401Unauthorized)] + public async Task Me() + { + if (!_telegram.IsConfigured || !_telegram.checkUserLogin()) + return StatusCode(StatusCodes.Status401Unauthorized, + ApiResult.Fail(ApiErrorCodes.NotLoggedIn, "No Telegram session is active")); + + var user = await BuildUserAsync(); + if (user == null) + return NotFoundResult("The Telegram user could not be resolved"); + + return OkResult(user); + } + + /// Advances the phone login flow one step. + /// + /// Post the phone number with isPhone: true to start. The response + /// tells you the next step; post the verification code (and then, when + /// required, the two-factor password) with isPhone: false. + /// + /// Sample sequence: + /// + /// POST /api/v1/auth/login { "value": "+34600000000", "isPhone": true } -> step "vc" + /// POST /api/v1/auth/login { "value": "12345" } -> step "pass" or "ok" + /// POST /api/v1/auth/login { "value": "my-2fa-password" } -> step "ok" + /// + /// + [HttpPost("login")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status400BadRequest)] + public async Task Login([FromBody] LoginStepRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.Value)) + return BadRequestResult("A value is required for the current login step"); + + try + { + if (!_telegram.IsConfigured) + _telegram.InitializeClient(); + + var step = await _telegram.checkAuth(request.Value.Trim(), request.IsPhone) ?? AuthStep.Phone; + + var dto = new AuthStatusDto + { + Step = step, + IsConfigured = _telegram.IsConfigured, + IsAuthenticated = step == AuthStep.Authenticated + }; + if (dto.IsAuthenticated) + dto.User = await BuildUserAsync(); + + return OkResult(dto); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Login step failed"); + return BadRequestResult("The login step was rejected by Telegram", ApiErrorCodes.InvalidRequest, ex.Message); + } + } + + /// Starts a QR login session. + /// + /// Render qrImageBase64 (a PNG) or encode loginUrl yourself, + /// then poll GET /api/v1/auth/qr/{sessionId}. Telegram rotates the + /// token every ~30 seconds, so keep repainting the QR from the polled + /// value. When the status turns password_required, post the 2FA + /// password to /api/v1/auth/qr/{sessionId}/password. + /// + /// Terminate any existing session before starting. + [HttpPost("qr")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task StartQr([FromQuery] bool logoutFirst = false) + { + try + { + if (!_telegram.IsConfigured) + _telegram.InitializeClient(); + + var session = await _qr.StartAsync(_telegram, logoutFirst); + return OkResult(session); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not start a QR login session"); + return ErrorResult("Could not start a QR login session", ex); + } + } + + /// Polls the state of a QR login session. + [HttpGet("qr/{sessionId}")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public IActionResult PollQr(string sessionId) + { + var session = _qr.Get(sessionId); + if (session == null) + return NotFoundResult("Unknown or expired QR login session"); + return OkResult(session); + } + + /// Supplies the two-factor password a QR session is waiting for. + [HttpPost("qr/{sessionId}/password")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public IActionResult ProvideQrPassword(string sessionId, [FromBody] QrPasswordRequest request) + { + if (request == null || string.IsNullOrEmpty(request.Password)) + return BadRequestResult("A password is required"); + + if (!_qr.ProvidePassword(sessionId, _telegram, request.Password)) + return NotFoundResult("Unknown or expired QR login session"); + + return OkResult(_qr.Get(sessionId)!); + } + + /// Cancels a pending QR login session. + [HttpDelete("qr/{sessionId}")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public IActionResult CancelQr(string sessionId) + { + if (!_qr.Cancel(sessionId)) + return NotFoundResult("Unknown or expired QR login session"); + return OkEmpty("QR login session cancelled"); + } + + /// Signs out of Telegram. + /// + /// This terminates the shared server session: the web UI is signed out + /// too and every client has to authenticate again. + /// + [HttpPost("logout")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task Logout() + { + try + { + await _telegram.logOff(); + return OkEmpty("Signed out"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error signing out"); + return ErrorResult("Could not sign out", ex); + } + } + + private async Task BuildUserAsync() + { + try + { + var user = await _telegram.GetUser(); + if (user == null) return null; + return new TelegramUserDto + { + Id = user.id, + Username = user.username, + FirstName = user.first_name, + LastName = user.last_name, + Phone = user.phone, + IsPremium = TelegramService.isPremium + }; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not resolve the Telegram user"); + return null; + } + } + } +} diff --git a/TelegramDownloader/Controllers/Api/V1/ChannelsController.cs b/TelegramDownloader/Controllers/Api/V1/ChannelsController.cs new file mode 100644 index 0000000..d0b392e --- /dev/null +++ b/TelegramDownloader/Controllers/Api/V1/ChannelsController.cs @@ -0,0 +1,685 @@ +using Microsoft.AspNetCore.Mvc; +using TelegramDownloader.Data; +using TelegramDownloader.Data.db; +using TelegramDownloader.Models; +using TelegramDownloader.Models.Api; +using TL; + +namespace TelegramDownloader.Controllers.Api.V1 +{ + /// + /// Telegram chats, channels and groups: discovery, favourites, folders, + /// creation and deletion, message history and index refresh. + /// + /// A "channel" in this API is any Telegram peer the account can see. When + /// the app indexes a channel it creates a MongoDB database named after the + /// channel id; that database is what the files endpoints browse. + /// + [Route("api/v1/channels")] + [Tags("Channels")] + [RequireTelegramSession] + public class ChannelsController : ApiV1ControllerBase + { + private readonly ITelegramService _telegram; + private readonly IFileService _files; + private readonly IDbService _db; + private readonly ILogger _logger; + + public ChannelsController( + ITelegramService telegram, + IFileService files, + IDbService db, + ILogger logger) + { + _telegram = telegram; + _files = files; + _db = db; + _logger = logger; + } + + /// Lists the chats the signed-in account can access. + /// + /// Set to list only the channels that + /// already have a local file index, which is what the file manager + /// navigates. Sorting accepts name (default) and id. + /// + /// Paging and sorting. + /// Only channels with a local index. + /// Only channels marked as favourite. + /// Only channels marked as hidden. + /// Include hidden channels even when the "show hidden channels" setting is off. + /// Case-insensitive substring match on the name. + [HttpGet] + [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)] + public async Task List( + [FromQuery] PagedQuery query, + [FromQuery] bool onlySaved = false, + [FromQuery] bool favoritesOnly = false, + [FromQuery] bool hiddenOnly = false, + [FromQuery] bool includeHidden = false, + [FromQuery] string? search = null) + { + try + { + var chats = onlySaved + ? await _telegram.getAllSavedChats() + : await _telegram.getAllChats(); + + // TelegramService.getAllSavedChats only respects the in-memory + // chat cache; it does NOT filter by local index. Cross-reference + // with the actual Mongo databases so onlySaved is honoured and + // HasDatabase is reliable in the response. + var indexedIds = await GetIndexedChannelIdsAsync(); + + var favourites = GeneralConfigStatic.config.FavouriteChannels ?? new List(); + var hidden = GeneralConfigStatic.config.HiddenChannels ?? new List(); + var items = (chats ?? new List()) + .Where(c => c?.chat != null) + .Where(c => !onlySaved || indexedIds.Contains(c.chat.ID)) + .Select(c => + { + var dto = ApiChannelDto.FromChatViewBase( + c, + isFavorite: favourites.Contains(c.chat.ID), + isOwner: SafeIsOwner(c.chat.ID), + isHidden: hidden.Contains(c.chat.ID)); + dto.HasDatabase = indexedIds.Contains(c.chat.ID); + return dto; + }) + .ToList(); + + if (favoritesOnly) + items = items.Where(c => c.IsFavorite).ToList(); + + // Hidden channels are excluded unless explicitly requested or the + // "show hidden channels" setting is on. + if (hiddenOnly) + items = items.Where(c => c.IsHidden).ToList(); + else if (!includeHidden && !GeneralConfigStatic.config.ShowHiddenChannels) + items = items.Where(c => !c.IsHidden).ToList(); + + if (!string.IsNullOrWhiteSpace(search)) + items = items.Where(c => c.Name.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); + + items = (query.SortBy?.ToLowerInvariant(), query.SortDescending) switch + { + ("id", true) => items.OrderByDescending(c => c.Id).ToList(), + ("id", false) => items.OrderBy(c => c.Id).ToList(), + (_, true) => items.OrderByDescending(c => c.Name).ToList(), + _ => items.OrderBy(c => c.Name).ToList() + }; + + var (page, info) = Paginate(items, query); + return OkPaged(page, info); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing channels"); + return ErrorResult("Could not list the channels", ex); + } + } + + /// Lists chats grouped by their Telegram folder (chat filter). + [HttpGet("folders")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task Folders() + { + try + { + var data = await _telegram.getChatsWithFolders(); + var favourites = GeneralConfigStatic.config.FavouriteChannels ?? new List(); + var hidden = GeneralConfigStatic.config.HiddenChannels ?? new List(); + var showHidden = GeneralConfigStatic.config.ShowHiddenChannels; + var indexedIds = await GetIndexedChannelIdsAsync(); + + ApiChannelDto ToDto(ChatViewBase c) + { + var dto = ApiChannelDto.FromChatViewBase(c, favourites.Contains(c.chat.ID), SafeIsOwner(c.chat.ID), hidden.Contains(c.chat.ID)); + dto.HasDatabase = indexedIds.Contains(c.chat.ID); + return dto; + } + bool Visible(ChatViewBase c) => c?.chat != null && (showHidden || !hidden.Contains(c.chat.ID)); + + var dto = new ApiChannelsWithFoldersDto + { + Folders = (data?.Folders ?? new List()).Select(f => new ApiChannelFolderDto + { + Id = f.Id, + Title = f.Title, + IconEmoji = f.IconEmoji, + Channels = (f.Chats ?? new List()) + .Where(Visible) + .Select(ToDto) + .ToList() + }).ToList(), + Ungrouped = (data?.UngroupedChats ?? new List()) + .Where(Visible) + .Select(ToDto) + .ToList() + }; + dto.TotalChannels = dto.Folders.Sum(f => f.ChannelCount) + dto.Ungrouped.Count; + + return OkResult(dto); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing channel folders"); + return ErrorResult("Could not list the channel folders", ex); + } + } + + /// Lists the favourite channels. + [HttpGet("favorites")] + [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)] + public async Task Favorites([FromQuery] bool refresh = true) + { + try + { + var chats = await _telegram.GetFouriteChannels(refresh); + var indexedIds = await GetIndexedChannelIdsAsync(); + var hidden = GeneralConfigStatic.config.HiddenChannels ?? new List(); + var items = (chats ?? new List()) + .Where(c => c?.chat != null) + .Select(c => + { + var dto = ApiChannelDto.FromChatViewBase(c, isFavorite: true, isOwner: SafeIsOwner(c.chat.ID), isHidden: hidden.Contains(c.chat.ID)); + dto.HasDatabase = indexedIds.Contains(c.chat.ID); + return dto; + }) + .OrderBy(c => c.Name) + .ToList(); + return OkResult(items); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing favourite channels"); + return ErrorResult("Could not list the favourite channels", ex); + } + } + + /// Marks a channel as favourite. + [HttpPost("{id}/favorite")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task AddFavorite(long id) + { + try + { + await _telegram.AddFavouriteChannel(id); + return OkEmpty("Channel added to favourites"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding channel {Id} to favourites", id); + return ErrorResult("Could not add the channel to favourites", ex); + } + } + + /// Removes a channel from the favourites. + [HttpDelete("{id}/favorite")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task RemoveFavorite(long id) + { + try + { + await _telegram.RemoveFavouriteChannel(id); + return OkEmpty("Channel removed from favourites"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing channel {Id} from favourites", id); + return ErrorResult("Could not remove the channel from favourites", ex); + } + } + + /// Lists the hidden channels. + [HttpGet("hidden")] + [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)] + public async Task Hidden() + { + try + { + var chats = await _telegram.GetHiddenChannels(); + var indexedIds = await GetIndexedChannelIdsAsync(); + var favourites = GeneralConfigStatic.config.FavouriteChannels ?? new List(); + var items = (chats ?? new List()) + .Where(c => c?.chat != null) + .Select(c => + { + var dto = ApiChannelDto.FromChatViewBase(c, favourites.Contains(c.chat.ID), SafeIsOwner(c.chat.ID), isHidden: true); + dto.HasDatabase = indexedIds.Contains(c.chat.ID); + return dto; + }) + .OrderBy(c => c.Name) + .ToList(); + return OkResult(items); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing hidden channels"); + return ErrorResult("Could not list the hidden channels", ex); + } + } + + /// Hides a channel from the channel lists. + [HttpPost("{id}/hidden")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task AddHidden(long id) + { + try + { + await _telegram.AddHiddenChannel(id); + return OkEmpty("Channel hidden"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error hiding channel {Id}", id); + return ErrorResult("Could not hide the channel", ex); + } + } + + /// Unhides a channel. + [HttpDelete("{id}/hidden")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task RemoveHidden(long id) + { + try + { + await _telegram.RemoveHiddenChannel(id); + return OkEmpty("Channel unhidden"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error unhiding channel {Id}", id); + return ErrorResult("Could not unhide the channel", ex); + } + } + + /// Details and indexed-content statistics of one channel. + [HttpGet("{id}")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public async Task Get(string id) + { + if (!long.TryParse(id, out var channelId)) + return BadRequestResult("The channel id must be numeric"); + + try + { + var (name, exists) = _telegram.GetChannelInfo(channelId); + if (!exists && name == null) + return NotFoundResult("Channel not found", ApiErrorCodes.ChannelNotFound); + + var isOwner = SafeIsOwner(channelId); + var dto = new ApiChannelDetailDto + { + Id = channelId, + Name = name ?? channelId.ToString(), + IsOwner = isOwner, + IsFavorite = (GeneralConfigStatic.config.FavouriteChannels ?? new List()).Contains(channelId), + IsHidden = (GeneralConfigStatic.config.HiddenChannels ?? new List()).Contains(channelId), + ImageUrl = $"/api/channel/image/{channelId}", + IsRefreshing = _files.isChannelRefreshing(id), + CanRefresh = !_telegram.isMyChat(channelId) || GeneralConfigStatic.config.EnableRefreshOwnChannels + }; + + try + { + var all = await _db.getAllDatabaseData(id); + dto.HasDatabase = all != null; + if (all != null) + { + var files = all.Where(f => f.IsFile).ToList(); + dto.FileCount = files.Count; + dto.FolderCount = all.Count - files.Count; + dto.TotalSize = files.Sum(f => f.Size); + dto.TotalSizeText = Services.HelperService.SizeSuffix(dto.TotalSize); + dto.AudioCount = files.Count(f => ApiFileDto.CategoryOf(f.Type) == "Audio"); + dto.VideoCount = files.Count(f => ApiFileDto.CategoryOf(f.Type) == "Video"); + dto.PhotoCount = files.Count(f => ApiFileDto.CategoryOf(f.Type) == "Photo"); + dto.DocumentCount = files.Count(f => ApiFileDto.CategoryOf(f.Type) == "Document"); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Channel {Id} has no local index yet", id); + dto.HasDatabase = false; + } + + return OkResult(dto); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading channel {Id}", id); + return ErrorResult("Could not read the channel", ex); + } + } + + /// Creates a Telegram channel owned by the signed-in account. + /// + /// With createDatabase: true (the default) the local file index is + /// created at the same time, so the channel can be used as a storage + /// target immediately. + /// + [HttpPost] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status400BadRequest)] + public async Task Create([FromBody] CreateChannelRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.Title)) + return BadRequestResult("A channel title is required"); + + try + { + var channel = await _telegram.CreateChannel(request.Title.Trim(), request.About ?? string.Empty); + if (channel == null) + return ErrorResult("Telegram did not return the created channel"); + + if (request.CreateDatabase) + await _files.CreateDatabase(channel.ID.ToString()); + + var dto = new ApiChannelDto + { + Id = channel.ID, + Name = channel.title, + Type = channel.IsGroup ? "group" : "channel", + IsOwner = true, + ImageUrl = $"/api/channel/image/{channel.ID}", + HasDatabase = request.CreateDatabase + }; + + return StatusCode(StatusCodes.Status201Created, ApiResult.Ok(dto, "Channel created")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating channel {Title}", request.Title); + return ErrorResult("Could not create the channel", ex); + } + } + + /// Creates the local file index (MongoDB database) for a channel. + [HttpPost("{id}/database")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task CreateDatabase(string id) + { + try + { + await _files.CreateDatabase(id); + return OkEmpty("Channel database created"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating database for channel {Id}", id); + return ErrorResult("Could not create the channel database", ex); + } + } + + /// Drops the local file index of a channel. + /// + /// Only the local index is removed: the files stay in Telegram, but the + /// app forgets the folder structure until the channel is refreshed again. + /// + [HttpDelete("{id}/database")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task DeleteDatabase(string id) + { + try + { + await _db.deleteDatabase(id); + return OkEmpty("Channel database deleted"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting database for channel {Id}", id); + return ErrorResult("Could not delete the channel database", ex); + } + } + + /// Leaves a channel, and optionally deletes it. + /// + /// With deleteOnTelegram: true the channel is deleted for every + /// member, which only works when the account owns it. This is + /// irreversible and also destroys the files stored inside. + /// + [HttpPost("{id}/leave")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status403Forbidden)] + public async Task Leave(long id, [FromBody] ChannelDeleteRequest? request) + { + request ??= new ChannelDeleteRequest(); + try + { + if (request.DeleteOnTelegram) + { + if (!_telegram.isChannelOwner(id)) + return ForbiddenResult("Only the channel owner can delete it"); + await _telegram.DeleteChannel(id); + } + else + { + await _telegram.LeaveChannel(id); + } + + if (request.DeleteLocalDatabase) + { + try { await _db.deleteDatabase(id.ToString()); } + catch (Exception ex) { _logger.LogWarning(ex, "Could not drop the local database of channel {Id}", id); } + } + + return OkEmpty(request.DeleteOnTelegram ? "Channel deleted" : "Channel left"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error leaving/deleting channel {Id}", id); + return ErrorResult("Could not leave or delete the channel", ex); + } + } + + /// Scans the channel on Telegram and indexes new files. + /// + /// The scan runs in the background and can take minutes on large + /// channels. Poll GET /api/v1/channels/{id}/refresh for the state, + /// and watch the transfers hub for the resulting activity. Only + /// files that are not indexed yet are added, so calling this repeatedly + /// is safe. + /// + [HttpPost("{id}/refresh")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status202Accepted)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status409Conflict)] + public IActionResult Refresh(string id, [FromBody] RefreshChannelRequest? request) + { + request ??= new RefreshChannelRequest(); + if (!request.ToOptions().HasAnySelection) + return BadRequestResult("Select at least one media type to fetch"); + + if (_files.isChannelRefreshing(id)) + return ConflictResult("This channel is already being refreshed", ApiErrorCodes.AlreadyRunning); + + // Fire and forget: the scan is long running and reports through the + // notification/transfer pipeline. + _ = Task.Run(async () => + { + try + { + await _files.refreshChannelFIles(id, request.Force, request.ToOptions()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Background refresh of channel {Id} failed", id); + } + }); + + return Accepted(ApiResult.Done("Channel refresh started")); + } + + /// Tells whether a background refresh is running for a channel. + [HttpGet("{id}/refresh")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult RefreshStatus(string id) => OkResult(_files.isChannelRefreshing(id)); + + /// Reads the recent message history of a chat. + /// + /// This hits Telegram directly and does not use the local index, so it + /// also works for channels that have never been indexed. + /// + /// Chat id. + /// Messages to return (1-100). + /// Messages to skip from the newest one. + /// Return only messages carrying a file. + [HttpGet("{id}/messages")] + [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)] + public async Task Messages( + long id, + [FromQuery] int limit = 30, + [FromQuery] int offset = 0, + [FromQuery] bool onlyMedia = false) + { + if (limit < 1) limit = 1; + if (limit > 100) limit = 100; + + try + { + var messages = await _telegram.getChatHistory(id, limit, offset); + var items = (messages ?? new List()) + .Where(m => m?.message != null) + .Select(ToMessageDto) + .Where(m => !onlyMedia || m.HasMedia) + .ToList(); + return OkResult(items); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading messages of chat {Id}", id); + return ErrorResult("Could not read the chat history", ex); + } + } + + /// Returns the channel avatar as a PNG/JPEG image. + [HttpGet("{id}/image")] + [Produces("image/jpeg", "image/png", "application/json")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public async Task Image(long id) + { + try + { + var bytes = await _telegram.DownloadChannelPhoto(id); + if (bytes == null || bytes.Length == 0) + return NotFoundResult("This channel has no avatar"); + return File(bytes, "image/jpeg"); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not download the avatar of channel {Id}", id); + return NotFoundResult("This channel has no avatar"); + } + } + + /// Returns the invitation link of a channel, generating one if needed. + [HttpGet("{id}/invitation")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public async Task Invitation(long id) + { + try + { + var info = await _telegram.getInvitationHash(id); + if (info == null) + return NotFoundResult("No invitation link is available for this channel"); + return OkResult(info); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading the invitation of channel {Id}", id); + return ErrorResult("Could not read the channel invitation", ex); + } + } + + /// Joins a channel using an invitation hash. + /// + /// The part after t.me/+ or joinchat/ in the invitation link. + /// + [HttpPost("join")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task Join([FromQuery] string hash) + { + if (string.IsNullOrWhiteSpace(hash)) + return BadRequestResult("An invitation hash is required"); + + try + { + await _telegram.joinChatInvitationHash(hash); + return OkEmpty("Joined the channel"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error joining with hash {Hash}", hash); + return ErrorResult("Could not join the channel", ex); + } + } + + private bool SafeIsOwner(long channelId) + { + try { return _telegram.isChannelOwner(channelId); } + catch { return false; } + } + + /// + /// Ids of channels that own a local Mongo database (index). The + /// TelegramService cache alone cannot answer this, so we ask the DB + /// layer directly. Returns an empty set on failure so callers keep + /// working (rows just miss the HasDatabase flag). + /// + private async Task> GetIndexedChannelIdsAsync() + { + try + { + var names = await _db.GetAllChannelDatabaseNames(); + var ids = new HashSet(); + foreach (var name in names ?? new List()) + { + if (long.TryParse(name, out var id)) ids.Add(id); + } + return ids; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not enumerate local channel databases; HasDatabase will not be populated"); + return new HashSet(); + } + } + + private static ApiChatMessageDto ToMessageDto(ChatMessages m) + { + var dto = new ApiChatMessageDto + { + Id = m.message.ID, + Date = m.message.Date, + Text = m.message.message, + From = m.user?.ToString() + }; + + switch (m.message.media) + { + case MessageMediaPhoto: + dto.HasMedia = true; + dto.MediaType = "photo"; + break; + case MessageMediaDocument { document: Document doc }: + dto.HasMedia = true; + dto.FileName = doc.Filename; + dto.FileSize = doc.size; + dto.MimeType = doc.mime_type; + dto.MediaType = doc.mime_type switch + { + not null when doc.mime_type.StartsWith("video") => "video", + not null when doc.mime_type.StartsWith("audio") => "audio", + not null when doc.mime_type.StartsWith("image") => "photo", + _ => "document" + }; + break; + } + + return dto; + } + } +} diff --git a/TelegramDownloader/Controllers/Api/V1/ConfigController.cs b/TelegramDownloader/Controllers/Api/V1/ConfigController.cs new file mode 100644 index 0000000..6d36ad6 --- /dev/null +++ b/TelegramDownloader/Controllers/Api/V1/ConfigController.cs @@ -0,0 +1,106 @@ +using Microsoft.AspNetCore.Mvc; +using TelegramDownloader.Data.db; +using TelegramDownloader.Models; +using TelegramDownloader.Models.Api; + +namespace TelegramDownloader.Controllers.Api.V1 +{ + /// + /// Application settings: transfer tuning, streaming behaviour, task + /// persistence and the WebDAV bridge. + /// + /// Settings are global and shared with the web UI: changing them here + /// changes them everywhere. + /// + [Route("api/v1/config")] + [Tags("Configuration")] + public class ConfigController : ApiV1ControllerBase + { + private readonly IDbService _db; + private readonly ILogger _logger; + + public ConfigController(IDbService db, ILogger logger) + { + _db = db; + _logger = logger; + } + + /// Reads the current configuration. + [HttpGet] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult Get() => OkResult(AppConfigDto.From(GeneralConfigStatic.config)); + + /// + /// Updates the configuration. Only the fields present in the body are + /// applied, so a client can change one setting without reading the rest. + /// + /// + /// A few values are clamped server-side: memorySplitSizeGB is + /// capped by the Telegram file-size limit of the account (4 GB for + /// Premium, 2 GB otherwise) and by splitSize. The response always + /// returns the effective configuration after clamping. + /// + [HttpPatch] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status400BadRequest)] + public async Task Update([FromBody] UpdateConfigRequest request) + { + if (request == null) + return BadRequestResult("A configuration body is required"); + + try + { + var c = GeneralConfigStatic.config; + + if (request.ShouldNotify.HasValue) c.ShouldNotify = request.ShouldNotify.Value; + if (request.TimeSleepBetweenTransactions.HasValue) c.TimeSleepBetweenTransactions = request.TimeSleepBetweenTransactions.Value; + if (request.SplitSize.HasValue) c.SplitSize = request.SplitSize.Value; + if (request.MaxSimultaneousDownloads.HasValue) c.MaxSimultaneousDownloads = Math.Max(1, request.MaxSimultaneousDownloads.Value); + if (request.CheckHash.HasValue) c.CheckHash = request.CheckHash.Value; + if (request.MaxImageUploadSizeInMb.HasValue) c.MaxImageUploadSizeInMb = request.MaxImageUploadSizeInMb.Value; + if (request.MaxPreloadFileSizeInMb.HasValue) c.MaxPreloadFileSizeInMb = request.MaxPreloadFileSizeInMb.Value; + if (request.ShouldShowCaptionPath.HasValue) c.ShouldShowCaptionPath = request.ShouldShowCaptionPath.Value; + if (request.ShouldShowLogInTerminal.HasValue) c.ShouldShowLogInTerminal = request.ShouldShowLogInTerminal.Value; + + if (!string.IsNullOrWhiteSpace(request.StrmStreamingMode)) + { + if (!Enum.TryParse(request.StrmStreamingMode, true, out var mode)) + return BadRequestResult("strmStreamingMode must be DirectStream, ProgressiveCache or Preload"); + c.StrmStreamingMode = mode; + } + + if (request.ShouldShowPaginatedFileChannel.HasValue) c.ShouldShowPaginatedFileChannel = request.ShouldShowPaginatedFileChannel.Value; + if (request.ShowChannelImages.HasValue) c.ShowChannelImages = request.ShowChannelImages.Value; + if (request.ShowHiddenChannels.HasValue) c.ShowHiddenChannels = request.ShowHiddenChannels.Value; + + if (request.EnableTaskPersistence.HasValue) c.EnableTaskPersistence = request.EnableTaskPersistence.Value; + if (request.TaskPersistenceDebounceSeconds.HasValue) c.TaskPersistenceDebounceSeconds = request.TaskPersistenceDebounceSeconds.Value; + if (request.StaleTaskCleanupDays.HasValue) c.StaleTaskCleanupDays = request.StaleTaskCleanupDays.Value; + if (request.AutoResumeOnStartup.HasValue) c.AutoResumeOnStartup = request.AutoResumeOnStartup.Value; + + if (request.EnableVideoTranscoding.HasValue) c.EnableVideoTranscoding = request.EnableVideoTranscoding.Value; + if (request.EnableRefreshOwnChannels.HasValue) c.EnableRefreshOwnChannels = request.EnableRefreshOwnChannels.Value; + + if (request.EnableMemorySplitUpload.HasValue) c.EnableMemorySplitUpload = request.EnableMemorySplitUpload.Value; + if (request.MemorySplitSizeGB.HasValue) c.MemorySplitSizeGB = request.MemorySplitSizeGB.Value; + if (request.ParallelTransfers.HasValue) c.ParallelTransfers = Math.Clamp(request.ParallelTransfers.Value, 1, 16); + + if (request.EnableMultiConnectionDownloads.HasValue) c.EnableMultiConnectionDownloads = request.EnableMultiConnectionDownloads.Value; + if (request.DownloadConnections.HasValue) c.DownloadConnections = Math.Clamp(request.DownloadConnections.Value, 2, 8); + if (request.MultiConnectionPartSizeKB.HasValue) c.MultiConnectionPartSizeKB = request.MultiConnectionPartSizeKB.Value; + if (request.MultiConnectionBlockSizeMB.HasValue) c.MultiConnectionBlockSizeMB = Math.Clamp(request.MultiConnectionBlockSizeMB.Value, 1, 16); + if (request.MultiConnectionMinFileSizeMB.HasValue) c.MultiConnectionMinFileSizeMB = request.MultiConnectionMinFileSizeMB.Value; + + await GeneralConfigStatic.SaveChanges(_db, c); + + return OkResult(AppConfigDto.From(GeneralConfigStatic.config), "Configuration saved"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating the configuration"); + return ErrorResult("Could not update the configuration", ex); + } + } + + } +} diff --git a/TelegramDownloader/Controllers/Api/V1/FilesController.cs b/TelegramDownloader/Controllers/Api/V1/FilesController.cs new file mode 100644 index 0000000..8932ab3 --- /dev/null +++ b/TelegramDownloader/Controllers/Api/V1/FilesController.cs @@ -0,0 +1,552 @@ +using Microsoft.AspNetCore.Mvc; +using Syncfusion.Blazor.FileManager; +using TelegramDownloader.Data; +using TelegramDownloader.Data.db; +using TelegramDownloader.Models; +using TelegramDownloader.Models.Api; +using TelegramDownloader.Services; +using TelegramDownloader.Services.Api; + +namespace TelegramDownloader.Controllers.Api.V1 +{ + /// + /// Browsing and managing the files a channel stores in Telegram, through the + /// local index. This mirrors the "Remote" tab of the web file manager. + /// + /// Folders are addressed either by path (/music/rock/) or by + /// folderId. Both are accepted everywhere; folderId wins when + /// both are present. + /// + [Route("api/v1/channels/{channelId}/files")] + [Tags("Files")] + [RequireTelegramSession] + public class FilesController : ApiV1ControllerBase + { + private readonly IDbService _db; + private readonly IFileService _files; + private readonly ChannelFolderResolver _resolver; + private readonly ILogger _logger; + + public FilesController( + IDbService db, + IFileService files, + ChannelFolderResolver resolver, + ILogger logger) + { + _db = db; + _files = files; + _resolver = resolver; + _logger = logger; + } + + /// Lists the contents of a folder. + /// + /// Folders are always returned before files. Supported sortBy + /// values are name (default), date, size and + /// type. filter narrows the result to one category: + /// audio, video, photo, document, + /// archive. + /// + /// Channel id (also the name of its index database). + /// Navigation, filtering, sorting and paging. + [HttpGet] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public async Task Browse(string channelId, [FromQuery] BrowseQuery query) + { + try + { + var folder = await _resolver.ResolveFolder(channelId, query.FolderId, query.Path); + if (folder == null) + return NotFoundResult("Folder not found"); + if (folder.IsFile) + return BadRequestResult("The requested id refers to a file, not a folder"); + + var children = await _resolver.ListChildren(channelId, folder); + var dto = BuildContents(channelId, folder, children, query, out var pageInfo); + return OkPaged(dto, pageInfo); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error browsing channel {ChannelId}", channelId); + return ErrorResult("Could not browse the channel", ex); + } + } + + /// Searches files by name across a subtree. + /// Channel id. + /// Text to look for (case-insensitive, substring match). + /// Scope (path), filtering, sorting and paging. + [HttpGet("search")] + [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)] + public async Task Search(string channelId, [FromQuery] string q, [FromQuery] BrowseQuery query) + { + if (string.IsNullOrWhiteSpace(q)) + return BadRequestResult("A search term is required"); + + try + { + var scope = ChannelFolderResolver.NormalizeFolderPath(query.Path); + var searchRoot = scope == "/" ? "" : scope.TrimEnd('/'); + var matches = await _db.Search(channelId, searchRoot, q); + + var items = (matches ?? new List()) + .Select(m => ApiFileDto.FromBson(m, channelId, BaseUrl)) + .ToList(); + + items = ApplyFilter(items, query); + items = ApplySort(items, query); + + var (pageItems, info) = Paginate(items, query); + return OkPaged(pageItems, info); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching channel {ChannelId} for '{Term}'", channelId, q); + return ErrorResult("Could not run the search", ex); + } + } + + /// Details of a single file or folder. + [HttpGet("{fileId}")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public async Task Get(string channelId, string fileId) + { + try + { + var entry = await _db.getFileById(channelId, fileId); + if (entry == null) + return NotFoundResult("File not found", ApiErrorCodes.FileNotFound); + return OkResult(ApiFileDto.FromBson(entry, channelId, BaseUrl)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading file {FileId} of channel {ChannelId}", fileId, channelId); + return ErrorResult("Could not read the file", ex); + } + } + + /// Aggregate size and file-type breakdown of a folder subtree. + [HttpGet("stats")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task Stats(string channelId, [FromQuery] string? path, [FromQuery] string? folderId) + { + try + { + var folder = await _resolver.ResolveFolder(channelId, folderId, path); + if (folder == null) + return NotFoundResult("Folder not found"); + + var childPath = ChannelFolderResolver.ChildFolderPath(folder); + var all = await _db.getAllChildFilesInDirectory(channelId, childPath); + var files = (all ?? new List()).Where(f => f.IsFile).ToList(); + + var stats = new ApiFolderStatsDto + { + FileCount = files.Count, + FolderCount = (all?.Count ?? 0) - files.Count, + AudioCount = files.Count(f => ApiFileDto.CategoryOf(f.Type) == "Audio"), + VideoCount = files.Count(f => ApiFileDto.CategoryOf(f.Type) == "Video"), + PhotoCount = files.Count(f => ApiFileDto.CategoryOf(f.Type) == "Photo"), + DocumentCount = files.Count(f => ApiFileDto.CategoryOf(f.Type) == "Document"), + TotalSize = files.Sum(f => f.Size) + }; + stats.TotalSizeText = HelperService.SizeSuffix(stats.TotalSize); + + return OkResult(stats); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error computing stats for channel {ChannelId}", channelId); + return ErrorResult("Could not compute the folder statistics", ex); + } + } + + /// Creates a folder. + [HttpPost("folders")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status409Conflict)] + public async Task CreateFolder(string channelId, [FromBody] CreateFolderRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.Name)) + return BadRequestResult("A folder name is required"); + if (request.Name.Contains('/') || request.Name.Contains('\\')) + return BadRequestResult("A folder name cannot contain path separators"); + + try + { + var parent = await _resolver.ResolveFolder(channelId, null, request.Path); + if (parent == null) + return NotFoundResult("Parent folder not found"); + + var created = await _files.createFolder( + channelId, + ChannelFolderResolver.CreateChildPath(parent), + request.Name.Trim(), + ChannelFolderResolver.ToContent(parent)); + + var first = created?.FirstOrDefault(); + if (first == null) + return ErrorResult("The folder was not created"); + + var entry = await _db.getFileById(channelId, first.Id); + var dto = entry != null + ? ApiFileDto.FromBson(entry, channelId, BaseUrl) + : new ApiFileDto { Id = first.Id, Name = request.Name, IsFile = false, Type = "folder", Category = "Folder" }; + + return StatusCode(StatusCodes.Status201Created, ApiResult.Ok(dto, "Folder created")); + } + catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey) + { + return ConflictResult("A folder with that name already exists here"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating folder in channel {ChannelId}", channelId); + return ErrorResult("Could not create the folder", ex); + } + } + + /// Renames a file or folder. + [HttpPut("{fileId}/name")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public async Task Rename(string channelId, string fileId, [FromBody] RenameRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.NewName)) + return BadRequestResult("A new name is required"); + if (request.NewName.Contains('/') || request.NewName.Contains('\\')) + return BadRequestResult("A name cannot contain path separators"); + + try + { + var entry = await _db.getFileById(channelId, fileId); + if (entry == null) + return NotFoundResult("File not found", ApiErrorCodes.FileNotFound); + + await _files.RenameFileOrFolder(channelId, ChannelFolderResolver.ToContent(entry), request.NewName.Trim()); + + var updated = await _db.getFileById(channelId, fileId); + return OkResult( + updated != null ? ApiFileDto.FromBson(updated, channelId, BaseUrl) : ApiFileDto.FromBson(entry, channelId, BaseUrl), + "Renamed"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error renaming {FileId} in channel {ChannelId}", fileId, channelId); + return ErrorResult("Could not rename the entry", ex); + } + } + + /// Deletes files and folders. + /// + /// Deleting also removes the underlying Telegram messages when no other + /// indexed entry references them, so this frees the channel storage. + /// Folders are deleted recursively. The operation is not reversible. + /// + [HttpPost("delete")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task Delete(string channelId, [FromBody] FileIdsRequest request) + { + if (request == null || request.Ids.Count == 0) + return BadRequestResult("At least one id is required"); + + var deleted = 0; + var skipped = new List(); + + foreach (var id in request.Ids) + { + try + { + var entry = await _db.getFileById(channelId, id); + if (entry == null) + { + skipped.Add(id); + continue; + } + await _files.oneItemDeleteAsync(channelId, ChannelFolderResolver.ToContent(entry)); + deleted++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not delete {FileId} in channel {ChannelId}", id, channelId); + skipped.Add(id); + } + } + + return OkResult(new TransferAcceptedDto { Accepted = deleted, Skipped = skipped }, + $"{deleted} entries deleted"); + } + + /// Copies files and folders to another folder of the same channel. + /// + /// Copies are index-level: the Telegram messages are shared, so a copy + /// consumes no extra channel storage. + /// + [HttpPost("copy")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public Task Copy(string channelId, [FromBody] CopyMoveRequest request) => + CopyOrMove(channelId, request, isCopy: true); + + /// Moves files and folders to another folder of the same channel. + [HttpPost("move")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public Task Move(string channelId, [FromBody] CopyMoveRequest request) => + CopyOrMove(channelId, request, isCopy: false); + + private async Task CopyOrMove(string channelId, CopyMoveRequest request, bool isCopy) + { + if (request == null || request.Ids.Count == 0) + return BadRequestResult("At least one id is required"); + + try + { + var target = await _resolver.ResolveFolder(channelId, request.TargetFolderId, request.TargetPath); + if (target == null || target.IsFile) + return NotFoundResult("Target folder not found"); + + var entries = new List(); + var skipped = new List(); + foreach (var id in request.Ids) + { + var entry = await _db.getFileById(channelId, id); + if (entry == null) skipped.Add(id); + else entries.Add(entry); + } + + if (entries.Count > 0) + { + var contents = entries.Select(ChannelFolderResolver.ToContent).ToArray(); + await _files.CopyOrMoveItems( + channelId, + contents, + ChannelFolderResolver.ChildFolderPath(target), + ChannelFolderResolver.ToContent(target), + isCopy); + } + + return OkResult(new TransferAcceptedDto { Accepted = entries.Count, Skipped = skipped }, + isCopy ? "Entries copied" : "Entries moved"); + } + catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey) + { + return ConflictResult("An entry with the same name already exists in the target folder"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error on {Operation} in channel {ChannelId}", isCopy ? "copy" : "move", channelId); + return ErrorResult(isCopy ? "Could not copy the entries" : "Could not move the entries", ex); + } + } + + /// Uploads a file directly into a channel folder. + /// + /// The request must be multipart/form-data with a file + /// part. The upload is streamed to Telegram and its progress is + /// published on the transfers hub like any other upload. + /// + /// To push files that already live on the server, use + /// POST /api/v1/transfers/uploads instead: it avoids sending the + /// bytes twice. + /// + /// Destination channel. + /// File part of the multipart body. + /// Destination folder inside the channel. Defaults to the root. + [HttpPost("upload")] + [RequestSizeLimit(long.MaxValue)] + [RequestFormLimits(MultipartBodyLengthLimit = long.MaxValue)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status202Accepted)] + public async Task Upload(string channelId, IFormFile file, [FromForm] string? path) + { + if (file == null || file.Length == 0) + return BadRequestResult("A non-empty file part is required"); + + try + { + var folder = ChannelFolderResolver.NormalizeFolderPath(path); + + // Stage the bytes under the local root, then reuse the regular + // server-to-Telegram pipeline so the upload shows up in the task + // list, is persisted and streams its progress over SignalR. + var stagingRelative = $"{ApiUploadStaging.FolderName}/{Guid.NewGuid():N}"; + var stagingAbsolute = Path.Combine(FileService.LOCALDIR, stagingRelative.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(stagingAbsolute); + + var safeName = Path.GetFileName(file.FileName); + await using (var fs = System.IO.File.Create(Path.Combine(stagingAbsolute, safeName))) + await file.CopyToAsync(fs); + + var content = new FileManagerDirectoryContent + { + Name = safeName, + IsFile = true, + Size = file.Length, + FilterPath = "/" + stagingRelative + "/", + Type = Path.GetExtension(safeName) + }; + + await _files.AddUploadFileFromServer(channelId, folder, new List { content }); + return Accepted(ApiResult.Done($"Upload of {safeName} started")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error uploading {FileName} to channel {ChannelId}", file?.FileName, channelId); + return ErrorResult("Could not upload the file", ex); + } + } + + /// Exports the whole channel index as a JSON document. + /// + /// The export can be re-imported into another instance with + /// POST /api/v1/channels/{channelId}/files/import, which is how + /// the app moves a library between servers. + /// + [HttpGet("export")] + [Produces("application/json")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Export(string channelId) + { + try + { + var ms = await _files.exportAllData(channelId); + ms.Position = 0; + return File(ms, "application/json", $"{channelId}.json"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error exporting channel {ChannelId}", channelId); + return ErrorResult("Could not export the channel index", ex); + } + } + + /// Imports a previously exported channel index. + /// + /// Send the export file as multipart/form-data in a file + /// part. Import runs in the background and reports through the + /// notification pipeline. + /// + [HttpPost("import")] + [RequestSizeLimit(long.MaxValue)] + [RequestFormLimits(MultipartBodyLengthLimit = long.MaxValue)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status202Accepted)] + public async Task Import(string channelId, IFormFile file) + { + if (file == null || file.Length == 0) + return BadRequestResult("A non-empty file part is required"); + + try + { + var tempPath = Path.Combine(FileService.TEMPDIR, $"import-{Guid.NewGuid():N}.json"); + Directory.CreateDirectory(FileService.TEMPDIR); + await using (var fs = System.IO.File.Create(tempPath)) + await file.CopyToAsync(fs); + + var progress = new GenericNotificationProgressModel(); + _ = Task.Run(async () => + { + try + { + await _files.importData(channelId, tempPath, progress); + } + catch (Exception ex) + { + _logger.LogError(ex, "Background import into channel {ChannelId} failed", channelId); + } + finally + { + try { System.IO.File.Delete(tempPath); } catch { } + } + }); + + return Accepted(ApiResult.Done("Import started")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error importing into channel {ChannelId}", channelId); + return ErrorResult("Could not import the channel index", ex); + } + } + + private ApiFolderContentsDto BuildContents( + string channelId, + BsonFileManagerModel folder, + List children, + BrowseQuery query, + out PageInfo pageInfo) + { + var all = children.Select(m => ApiFileDto.FromBson(m, channelId, BaseUrl)).ToList(); + + var stats = new ApiFolderStatsDto + { + FolderCount = all.Count(i => !i.IsFile), + FileCount = all.Count(i => i.IsFile), + AudioCount = all.Count(i => i.Category == "Audio"), + VideoCount = all.Count(i => i.Category == "Video"), + PhotoCount = all.Count(i => i.Category == "Photo"), + DocumentCount = all.Count(i => i.Category == "Document"), + TotalSize = all.Where(i => i.IsFile).Sum(i => i.Size) + }; + stats.TotalSizeText = HelperService.SizeSuffix(stats.TotalSize); + + var items = query.FilesOnly ? all.Where(i => i.IsFile).ToList() : all; + items = ApplyFilter(items, query); + + if (!string.IsNullOrWhiteSpace(query.Search)) + items = items.Where(i => i.Name.Contains(query.Search, StringComparison.OrdinalIgnoreCase)).ToList(); + + items = ApplySort(items, query); + + var (pageItems, info) = Paginate(items, query); + pageInfo = info; + + var crumbs = ChannelFolderResolver.Breadcrumbs(folder); + var currentPath = ChannelFolderResolver.ChildFolderPath(folder); + + return new ApiFolderContentsDto + { + ChannelId = channelId, + CurrentPath = currentPath, + CurrentFolderId = folder.Id, + ParentFolderId = string.IsNullOrEmpty(folder.ParentId) ? null : folder.ParentId, + ParentPath = currentPath == "/" ? null : (crumbs.Count > 1 ? crumbs[^2].Path : "/"), + FolderName = folder.Name, + Items = pageItems, + Stats = stats, + Breadcrumbs = crumbs.Select(c => new ApiBreadcrumbDto { Name = c.Name, Path = c.Path }).ToList() + }; + } + + private static List ApplyFilter(List items, BrowseQuery query) + { + if (string.IsNullOrWhiteSpace(query.Filter) || query.Filter.Equals("all", StringComparison.OrdinalIgnoreCase)) + return items; + + var wanted = query.Filter.Trim().ToLowerInvariant() switch + { + "audio" => "Audio", + "video" => "Video", + "photo" or "photos" or "image" or "images" => "Photo", + "document" or "documents" or "doc" => "Document", + "archive" or "archives" => "Archive", + _ => query.Filter + }; + + return items.Where(i => !i.IsFile || i.Category.Equals(wanted, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + private static List ApplySort(List items, BrowseQuery query) => + (query.SortBy?.ToLowerInvariant(), query.SortDescending) switch + { + ("date", true) => items.OrderBy(i => i.IsFile).ThenByDescending(i => i.DateModified).ToList(), + ("date", false) => items.OrderBy(i => i.IsFile).ThenBy(i => i.DateModified).ToList(), + ("size", true) => items.OrderBy(i => i.IsFile).ThenByDescending(i => i.Size).ToList(), + ("size", false) => items.OrderBy(i => i.IsFile).ThenBy(i => i.Size).ToList(), + ("type", true) => items.OrderBy(i => i.IsFile).ThenByDescending(i => i.Type).ToList(), + ("type", false) => items.OrderBy(i => i.IsFile).ThenBy(i => i.Type).ToList(), + (_, true) => items.OrderBy(i => i.IsFile).ThenByDescending(i => i.Name).ToList(), + _ => items.OrderBy(i => i.IsFile).ThenBy(i => i.Name).ToList() + }; + } +} diff --git a/TelegramDownloader/Controllers/Api/V1/LocalFilesController.cs b/TelegramDownloader/Controllers/Api/V1/LocalFilesController.cs new file mode 100644 index 0000000..184f250 --- /dev/null +++ b/TelegramDownloader/Controllers/Api/V1/LocalFilesController.cs @@ -0,0 +1,407 @@ +using Microsoft.AspNetCore.Mvc; +using TelegramDownloader.Data; +using TelegramDownloader.Models.Api; +using TelegramDownloader.Services; +using TelegramDownloader.Services.Api; + +namespace TelegramDownloader.Controllers.Api.V1 +{ + /// + /// The server's local storage: the folder downloads land in and uploads are + /// taken from. This mirrors the "Local" tab of the web file manager. + /// + /// Every path is relative to the local root and is validated against + /// directory traversal; absolute paths and .. segments that escape + /// the root are rejected with 400 invalid_request. + /// + [Route("api/v1/local")] + [Tags("Local files")] + public class LocalFilesController : ApiV1ControllerBase + { + private readonly ILogger _logger; + + public LocalFilesController(ILogger logger) + { + _logger = logger; + } + + /// Lists a local directory. + /// + /// Use an empty path for the root. Folders come first; sorting + /// and filtering behave exactly like the channel browse endpoint. + /// + [HttpGet] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public IActionResult Browse([FromQuery] BrowseQuery query) + { + if (!TryResolve(query.Path, out var absolute, out var relative, out var error)) + return BadRequestResult(error!); + + if (!Directory.Exists(absolute)) + return NotFoundResult("Directory not found"); + + try + { + var dir = new DirectoryInfo(absolute); + var items = new List(); + + foreach (var sub in dir.GetDirectories()) + items.Add(ApiFileDto.FromLocalDirectory(sub, Join(relative, sub.Name))); + + foreach (var file in dir.GetFiles()) + items.Add(ApiFileDto.FromLocalFile(file, Join(relative, file.Name), BaseUrl)); + + var stats = new ApiFolderStatsDto + { + FolderCount = items.Count(i => !i.IsFile), + FileCount = items.Count(i => i.IsFile), + AudioCount = items.Count(i => i.Category == "Audio"), + VideoCount = items.Count(i => i.Category == "Video"), + PhotoCount = items.Count(i => i.Category == "Photo"), + DocumentCount = items.Count(i => i.Category == "Document"), + TotalSize = items.Where(i => i.IsFile).Sum(i => i.Size) + }; + stats.TotalSizeText = HelperService.SizeSuffix(stats.TotalSize); + + var filtered = query.FilesOnly ? items.Where(i => i.IsFile).ToList() : items; + filtered = ApplyFilter(filtered, query.Filter); + + if (!string.IsNullOrWhiteSpace(query.Search)) + filtered = filtered.Where(i => i.Name.Contains(query.Search, StringComparison.OrdinalIgnoreCase)).ToList(); + + filtered = ApplySort(filtered, query); + + var (pageItems, page) = Paginate(filtered, query); + + var parent = string.IsNullOrEmpty(relative) + ? null + : (Path.GetDirectoryName(relative)?.Replace("\\", "/") ?? string.Empty); + + var dto = new ApiFolderContentsDto + { + CurrentPath = "/" + relative, + CurrentFolderId = relative, + ParentPath = parent, + FolderName = string.IsNullOrEmpty(relative) ? "Local" : dir.Name, + Items = pageItems, + Stats = stats, + Breadcrumbs = BuildBreadcrumbs(relative) + }; + + return OkPaged(dto, page); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing local path {Path}", query.Path); + return ErrorResult("Could not list the directory", ex); + } + } + + /// Metadata of one local file or directory. + [HttpGet("info")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public IActionResult Info([FromQuery] string path) + { + if (!TryResolve(path, out var absolute, out var relative, out var error)) + return BadRequestResult(error!); + + if (System.IO.File.Exists(absolute)) + return OkResult(ApiFileDto.FromLocalFile(new FileInfo(absolute), relative, BaseUrl)); + + if (Directory.Exists(absolute)) + return OkResult(ApiFileDto.FromLocalDirectory(new DirectoryInfo(absolute), relative)); + + return NotFoundResult("Path not found"); + } + + /// Recursive size and file-type breakdown of a local directory. + [HttpGet("size")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task Size([FromQuery] string? path) + { + if (!TryResolve(path, out var absolute, out _, out var error)) + return BadRequestResult(error!); + + if (!Directory.Exists(absolute)) + return NotFoundResult("Directory not found"); + + try + { + return OkResult(await HelperService.GetDirecctorySizeAsync(absolute)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error measuring local path {Path}", path); + return ErrorResult("Could not measure the directory", ex); + } + } + + /// Creates a local directory. + [HttpPost("folders")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status201Created)] + public IActionResult CreateFolder([FromBody] LocalCreateFolderRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.Name)) + return BadRequestResult("A folder name is required"); + if (request.Name.Contains('/') || request.Name.Contains('\\')) + return BadRequestResult("A folder name cannot contain path separators"); + + if (!TryResolve(request.Path, out var parentAbsolute, out var parentRelative, out var error)) + return BadRequestResult(error!); + + try + { + var target = Path.Combine(parentAbsolute, request.Name.Trim()); + if (Directory.Exists(target)) + return ConflictResult("A folder with that name already exists"); + + var info = Directory.CreateDirectory(target); + var dto = ApiFileDto.FromLocalDirectory(info, Join(parentRelative, info.Name)); + return StatusCode(StatusCodes.Status201Created, ApiResult.Ok(dto, "Folder created")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating local folder under {Path}", request.Path); + return ErrorResult("Could not create the folder", ex); + } + } + + /// Renames a local file or directory. + [HttpPost("rename")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult Rename([FromBody] LocalRenameRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.NewName)) + return BadRequestResult("A new name is required"); + if (request.NewName.Contains('/') || request.NewName.Contains('\\')) + return BadRequestResult("A name cannot contain path separators"); + + if (!TryResolve(request.Path, out var absolute, out var relative, out var error)) + return BadRequestResult(error!); + + try + { + var parentAbsolute = Path.GetDirectoryName(absolute)!; + var parentRelative = Path.GetDirectoryName(relative)?.Replace("\\", "/") ?? string.Empty; + var target = Path.Combine(parentAbsolute, request.NewName.Trim()); + + if (System.IO.File.Exists(absolute)) + { + if (System.IO.File.Exists(target)) return ConflictResult("A file with that name already exists"); + System.IO.File.Move(absolute, target); + return OkResult(ApiFileDto.FromLocalFile(new FileInfo(target), Join(parentRelative, request.NewName.Trim()), BaseUrl), "Renamed"); + } + + if (Directory.Exists(absolute)) + { + if (Directory.Exists(target)) return ConflictResult("A folder with that name already exists"); + Directory.Move(absolute, target); + return OkResult(ApiFileDto.FromLocalDirectory(new DirectoryInfo(target), Join(parentRelative, request.NewName.Trim())), "Renamed"); + } + + return NotFoundResult("Path not found"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error renaming local path {Path}", request.Path); + return ErrorResult("Could not rename the entry", ex); + } + } + + /// Deletes local files and directories. + /// Directories are deleted recursively and the data is not recoverable. + [HttpPost("delete")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult Delete([FromBody] LocalDeleteRequest request) + { + if (request == null || request.Paths.Count == 0) + return BadRequestResult("At least one path is required"); + + var deleted = 0; + var skipped = new List(); + + foreach (var path in request.Paths) + { + if (!TryResolve(path, out var absolute, out _, out _)) + { + skipped.Add(path); + continue; + } + + try + { + if (System.IO.File.Exists(absolute)) { System.IO.File.Delete(absolute); deleted++; } + else if (Directory.Exists(absolute)) { Directory.Delete(absolute, true); deleted++; } + else skipped.Add(path); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not delete local path {Path}", path); + skipped.Add(path); + } + } + + return OkResult(new TransferAcceptedDto { Accepted = deleted, Skipped = skipped }, $"{deleted} entries deleted"); + } + + /// Downloads a local file. + /// + /// Supports HTTP range requests, so it can be used directly as a media + /// source by a player. + /// + [HttpGet("download")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public IActionResult Download([FromQuery] string path) + { + if (!TryResolve(path, out var absolute, out _, out var error)) + return BadRequestResult(error!); + + if (!System.IO.File.Exists(absolute)) + return NotFoundResult("File not found", ApiErrorCodes.FileNotFound); + + var stream = new FileStream(absolute, FileMode.Open, FileAccess.Read, FileShare.Read); + return File(stream, FileService.getMimeType(Path.GetExtension(absolute)) ?? "application/octet-stream", + Path.GetFileName(absolute), enableRangeProcessing: true); + } + + /// Uploads a file into the local storage. + /// + /// Send multipart/form-data with a file part. To then push + /// it to Telegram, call POST /api/v1/transfers/uploads with the + /// returned path. + /// + [HttpPost("upload")] + [RequestSizeLimit(long.MaxValue)] + [RequestFormLimits(MultipartBodyLengthLimit = long.MaxValue)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status201Created)] + public async Task Upload(IFormFile file, [FromForm] string? path) + { + if (file == null || file.Length == 0) + return BadRequestResult("A non-empty file part is required"); + + if (!TryResolve(path, out var absolute, out var relative, out var error)) + return BadRequestResult(error!); + + try + { + Directory.CreateDirectory(absolute); + var safeName = Path.GetFileName(file.FileName); + var target = Path.Combine(absolute, safeName); + + await using (var fs = System.IO.File.Create(target)) + await file.CopyToAsync(fs); + + var dto = ApiFileDto.FromLocalFile(new FileInfo(target), Join(relative, safeName), BaseUrl); + return StatusCode(StatusCodes.Status201Created, ApiResult.Ok(dto, "File stored")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error storing an upload under {Path}", path); + return ErrorResult("Could not store the file", ex); + } + } + + /// Empties the streaming/temporary cache folder. + /// + /// The cache holds files pulled from Telegram for playback. Clearing it + /// frees disk space; the next playback re-downloads what it needs. + /// + [HttpPost("cache/clear")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult ClearCache([FromServices] IFileService files) + { + try + { + files.cleanTempFolder(); + return OkEmpty("Temporary cache cleared"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error clearing the temporary cache"); + return ErrorResult("Could not clear the temporary cache", ex); + } + } + + private static List BuildBreadcrumbs(string relative) + { + var crumbs = new List { new() { Name = "Local", Path = "", FolderId = "" } }; + if (string.IsNullOrEmpty(relative)) return crumbs; + + var acc = string.Empty; + foreach (var segment in relative.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + acc = string.IsNullOrEmpty(acc) ? segment : acc + "/" + segment; + crumbs.Add(new ApiBreadcrumbDto { Name = segment, Path = acc, FolderId = acc }); + } + return crumbs; + } + + private static string Join(string parent, string name) => + string.IsNullOrEmpty(parent) ? name : parent.TrimEnd('/') + "/" + name; + + /// + /// Resolves a client path against the local root, refusing anything that + /// escapes it. + /// + private static bool TryResolve(string? path, out string absolute, out string relative, out string? error) + { + absolute = string.Empty; + relative = string.Empty; + error = null; + + var candidate = (path ?? string.Empty).Replace("\\", "/").Trim().TrimStart('/'); + if (Path.IsPathRooted(candidate)) + { + error = "Only paths relative to the local root are accepted"; + return false; + } + + var root = Path.GetFullPath(FileService.LOCALDIR); + var full = Path.GetFullPath(Path.Combine(root, candidate)); + + if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + { + error = "The path escapes the local root"; + return false; + } + + absolute = full; + relative = candidate.Trim('/'); + return true; + } + + private static List ApplyFilter(List items, string? filter) + { + if (string.IsNullOrWhiteSpace(filter) || filter.Equals("all", StringComparison.OrdinalIgnoreCase)) + return items; + + var wanted = filter.Trim().ToLowerInvariant() switch + { + "audio" => "Audio", + "video" => "Video", + "photo" or "photos" or "image" or "images" => "Photo", + "document" or "documents" or "doc" => "Document", + "archive" or "archives" => "Archive", + _ => filter + }; + + return items.Where(i => !i.IsFile || i.Category.Equals(wanted, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + private static List ApplySort(List items, BrowseQuery query) => + (query.SortBy?.ToLowerInvariant(), query.SortDescending) switch + { + ("date", true) => items.OrderBy(i => i.IsFile).ThenByDescending(i => i.DateModified).ToList(), + ("date", false) => items.OrderBy(i => i.IsFile).ThenBy(i => i.DateModified).ToList(), + ("size", true) => items.OrderBy(i => i.IsFile).ThenByDescending(i => i.Size).ToList(), + ("size", false) => items.OrderBy(i => i.IsFile).ThenBy(i => i.Size).ToList(), + ("type", true) => items.OrderBy(i => i.IsFile).ThenByDescending(i => i.Type).ToList(), + ("type", false) => items.OrderBy(i => i.IsFile).ThenBy(i => i.Type).ToList(), + (_, true) => items.OrderBy(i => i.IsFile).ThenByDescending(i => i.Name).ToList(), + _ => items.OrderBy(i => i.IsFile).ThenBy(i => i.Name).ToList() + }; + } +} diff --git a/TelegramDownloader/Controllers/Api/V1/PlaylistsController.cs b/TelegramDownloader/Controllers/Api/V1/PlaylistsController.cs new file mode 100644 index 0000000..42e82b8 --- /dev/null +++ b/TelegramDownloader/Controllers/Api/V1/PlaylistsController.cs @@ -0,0 +1,268 @@ +using Microsoft.AspNetCore.Mvc; +using TelegramDownloader.Data; +using TelegramDownloader.Data.db; +using TelegramDownloader.Models; +using TelegramDownloader.Models.Api; + +namespace TelegramDownloader.Controllers.Api.V1 +{ + /// + /// Playlists mixing Telegram-hosted tracks and local files, shared with the + /// web player and the audio app. + /// + /// A track either points at an indexed channel file (channelId + + /// fileId) or at a local file (directUrl). + /// + [Route("api/v1/playlists")] + [Tags("Playlists")] + public class PlaylistsController : ApiV1ControllerBase + { + private readonly IDbService _db; + private readonly IFileService _files; + private readonly ILogger _logger; + + public PlaylistsController(IDbService db, IFileService files, ILogger logger) + { + _db = db; + _files = files; + _logger = logger; + } + + /// Lists every playlist. + [HttpGet] + [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)] + public async Task List([FromQuery] PagedQuery? query = null) + { + query ??= new PagedQuery(); + try + { + var playlists = await _db.GetAllPlaylists() ?? new List(); + var ordered = (query.SortBy?.ToLowerInvariant(), query.SortDescending) switch + { + ("date", true) => playlists.OrderByDescending(p => p.DateModified).ToList(), + ("date", false) => playlists.OrderBy(p => p.DateModified).ToList(), + ("tracks", true) => playlists.OrderByDescending(p => p.TrackCount).ToList(), + ("tracks", false) => playlists.OrderBy(p => p.TrackCount).ToList(), + (_, true) => playlists.OrderByDescending(p => p.Name).ToList(), + _ => playlists.OrderBy(p => p.Name).ToList() + }; + + var (items, page) = Paginate(ordered, query); + return OkPaged(items, page); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing playlists"); + return ErrorResult("Could not list the playlists", ex); + } + } + + /// One playlist with all of its tracks, in order. + [HttpGet("{id}")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public async Task Get(string id) + { + try + { + var playlist = await _db.GetPlaylistById(id); + if (playlist == null) + return NotFoundResult("Playlist not found", ApiErrorCodes.PlaylistNotFound); + + playlist.Tracks = (playlist.Tracks ?? new List()).OrderBy(t => t.Order).ToList(); + return OkResult(playlist); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading playlist {Id}", id); + return ErrorResult("Could not read the playlist", ex); + } + } + + /// Creates a playlist. + [HttpPost] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status201Created)] + public async Task Create([FromBody] PlaylistModel playlist) + { + if (playlist == null || string.IsNullOrWhiteSpace(playlist.Name)) + return BadRequestResult("A playlist name is required"); + + try + { + playlist.DateCreated = DateTime.Now; + playlist.DateModified = DateTime.Now; + var created = await _db.CreatePlaylist(playlist); + return StatusCode(StatusCodes.Status201Created, ApiResult.Ok(created, "Playlist created")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating playlist {Name}", playlist.Name); + return ErrorResult("Could not create the playlist", ex); + } + } + + /// Updates a playlist's name, description or full track list. + [HttpPut("{id}")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public async Task Update(string id, [FromBody] PlaylistModel playlist) + { + if (playlist == null) + return BadRequestResult("A playlist body is required"); + + try + { + var existing = await _db.GetPlaylistById(id); + if (existing == null) + return NotFoundResult("Playlist not found", ApiErrorCodes.PlaylistNotFound); + + existing.Name = string.IsNullOrWhiteSpace(playlist.Name) ? existing.Name : playlist.Name; + existing.Description = playlist.Description ?? existing.Description; + if (playlist.Tracks != null && playlist.Tracks.Count > 0) + existing.Tracks = playlist.Tracks; + existing.DateModified = DateTime.Now; + + await _db.UpdatePlaylist(existing); + return OkResult(existing, "Playlist updated"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating playlist {Id}", id); + return ErrorResult("Could not update the playlist", ex); + } + } + + /// Deletes a playlist. + [HttpDelete("{id}")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task Delete(string id) + { + try + { + await _db.DeletePlaylist(id); + return OkEmpty("Playlist deleted"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting playlist {Id}", id); + return ErrorResult("Could not delete the playlist", ex); + } + } + + /// Appends a track to a playlist. + [HttpPost("{id}/tracks")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public async Task AddTrack(string id, [FromBody] PlaylistTrackModel track) + { + if (track == null || (string.IsNullOrWhiteSpace(track.FileId) && string.IsNullOrWhiteSpace(track.DirectUrl))) + return BadRequestResult("A track needs either a fileId or a directUrl"); + + try + { + var playlist = await _db.GetPlaylistById(id); + if (playlist == null) + return NotFoundResult("Playlist not found", ApiErrorCodes.PlaylistNotFound); + + track.Order = (playlist.Tracks?.Count ?? 0); + track.DateAdded = DateTime.Now; + await _db.AddTrackToPlaylist(id, track); + + return OkResult(await _db.GetPlaylistById(id) ?? playlist, "Track added"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding a track to playlist {Id}", id); + return ErrorResult("Could not add the track", ex); + } + } + + /// Removes a track from a playlist. + [HttpDelete("{id}/tracks/{fileId}")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task RemoveTrack(string id, string fileId) + { + try + { + var playlist = await _db.GetPlaylistById(id); + if (playlist == null) + return NotFoundResult("Playlist not found", ApiErrorCodes.PlaylistNotFound); + + await _db.RemoveTrackFromPlaylist(id, fileId); + return OkResult(await _db.GetPlaylistById(id) ?? playlist, "Track removed"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing track {FileId} from playlist {Id}", fileId, id); + return ErrorResult("Could not remove the track", ex); + } + } + + /// Reorders the tracks of a playlist. + /// Playlist id. + /// File ids in the desired order. + [HttpPut("{id}/tracks/order")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task Reorder(string id, [FromBody] List orderedFileIds) + { + if (orderedFileIds == null || orderedFileIds.Count == 0) + return BadRequestResult("An ordered list of file ids is required"); + + try + { + var playlist = await _db.GetPlaylistById(id); + if (playlist == null) + return NotFoundResult("Playlist not found", ApiErrorCodes.PlaylistNotFound); + + await _db.ReorderPlaylistTracks(id, orderedFileIds); + return OkResult(await _db.GetPlaylistById(id) ?? playlist, "Playlist reordered"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reordering playlist {Id}", id); + return ErrorResult("Could not reorder the playlist", ex); + } + } + + /// Downloads every track of a playlist to the local storage. + /// + /// Runs in the background and reports on the transfers hub like + /// any other download. + /// + /// Playlist id. + /// Folder relative to the local root. + [HttpPost("{id}/download")] + [RequireTelegramSession] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status202Accepted)] + public async Task Download(string id, [FromQuery] string? destinationFolder) + { + try + { + var playlist = await _db.GetPlaylistById(id); + if (playlist == null) + return NotFoundResult("Playlist not found", ApiErrorCodes.PlaylistNotFound); + + var folder = string.IsNullOrWhiteSpace(destinationFolder) ? playlist.Name : destinationFolder; + + _ = Task.Run(async () => + { + try + { + await _files.DownloadPlaylistToLocal(playlist, folder); + } + catch (Exception ex) + { + _logger.LogError(ex, "Background download of playlist {Id} failed", id); + } + }); + + return Accepted(ApiResult.Done("Playlist download started")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting the download of playlist {Id}", id); + return ErrorResult("Could not start the playlist download", ex); + } + } + } +} diff --git a/TelegramDownloader/Controllers/Api/V1/RequireTelegramSessionAttribute.cs b/TelegramDownloader/Controllers/Api/V1/RequireTelegramSessionAttribute.cs new file mode 100644 index 0000000..c2d1588 --- /dev/null +++ b/TelegramDownloader/Controllers/Api/V1/RequireTelegramSessionAttribute.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using TelegramDownloader.Data; +using TelegramDownloader.Models.Api; + +namespace TelegramDownloader.Controllers.Api.V1 +{ + /// + /// Rejects the request with 401 not_logged_in (or 503 + /// setup_required) when no Telegram session is active. + /// + /// The API key protects the endpoint; this attribute protects the operation, + /// which additionally needs a signed-in Telegram account. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public class RequireTelegramSessionAttribute : Attribute, IAsyncActionFilter + { + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var telegram = context.HttpContext.RequestServices.GetService(); + + if (telegram == null || !telegram.IsConfigured) + { + context.Result = new ObjectResult(ApiResult.Fail( + ApiErrorCodes.SetupRequired, + "The application has not been configured yet. See GET /api/v1/system/setup.")) + { + StatusCode = StatusCodes.Status503ServiceUnavailable + }; + return; + } + + bool loggedIn; + try + { + loggedIn = telegram.checkUserLogin(); + } + catch + { + loggedIn = false; + } + + if (!loggedIn) + { + context.Result = new ObjectResult(ApiResult.Fail( + ApiErrorCodes.NotLoggedIn, + "No Telegram session is active. Sign in through /api/v1/auth.")) + { + StatusCode = StatusCodes.Status401Unauthorized + }; + return; + } + + await next(); + } + } +} diff --git a/TelegramDownloader/Controllers/Api/V1/SharesController.cs b/TelegramDownloader/Controllers/Api/V1/SharesController.cs new file mode 100644 index 0000000..f5938c1 --- /dev/null +++ b/TelegramDownloader/Controllers/Api/V1/SharesController.cs @@ -0,0 +1,233 @@ +using Microsoft.AspNetCore.Mvc; +using TelegramDownloader.Data; +using TelegramDownloader.Data.db; +using TelegramDownloader.Models; +using TelegramDownloader.Models.Api; + +namespace TelegramDownloader.Controllers.Api.V1 +{ + /// + /// Sharing a folder of a channel with another TelegramFileManager instance, + /// and importing what somebody else shared. + /// + /// A share is a portable description of the files (names, sizes, Telegram + /// message ids) plus an invitation to the channel that holds them. The + /// bytes stay in Telegram: importing a share only rebuilds the index and, + /// when needed, joins the channel. + /// + [Route("api/v1/shares")] + [Tags("Shares")] + [RequireTelegramSession] + public class SharesController : ApiV1ControllerBase + { + private readonly IFileService _files; + private readonly IDbService _db; + private readonly ITelegramService _telegram; + private readonly ILogger _logger; + + public SharesController( + IFileService files, + IDbService db, + ITelegramService telegram, + ILogger logger) + { + _files = files; + _db = db; + _telegram = telegram; + _logger = logger; + } + + /// Lists the shared collections stored on this server. + [HttpGet] + [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)] + public async Task List([FromQuery] string? filter, [FromQuery] PagedQuery? query = null) + { + query ??= new PagedQuery(); + try + { + var list = await _db.getSharedInfoList(filter: filter) ?? new List(); + var items = list.Select(ToDto).OrderByDescending(s => s.DateModified).ToList(); + var (page, info) = Paginate(items, query); + return OkPaged(page, info); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing shared collections"); + return ErrorResult("Could not list the shared collections", ex); + } + } + + /// Details of one shared collection. + [HttpGet("{id}")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public async Task Get(string id) + { + try + { + var info = await _files.GetSharedInfoById(id); + if (info == null) + return NotFoundResult("Shared collection not found"); + return OkResult(ToDto(info)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading shared collection {Id}", id); + return ErrorResult("Could not read the shared collection", ex); + } + } + + /// Builds a share payload for a channel folder. + /// + /// The returned document is what another instance passes to + /// POST /api/v1/shares/import. It contains the file descriptors + /// and, when available, an invitation link to the channel, so the + /// receiving account can join and read the files. + /// + /// Channel that holds the files. + /// Folder to share. Omit to share the whole channel. + /// Label for the share. + [HttpGet("export")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task Export( + [FromQuery] string channelId, + [FromQuery] string? folderId, + [FromQuery] string? name) + { + if (string.IsNullOrWhiteSpace(channelId)) + return BadRequestResult("A channel id is required"); + + try + { + var share = new ShareFilesModel + { + id = channelId, + name = name, + fileName = name, + files = await _files.ShareFile(channelId, folderId) + }; + + try + { + share.chatName = _telegram.getChatName(Convert.ToInt64(channelId)); + share.invitation = await _telegram.getInvitationHash(Convert.ToInt64(channelId)); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not attach an invitation to the share of channel {ChannelId}", channelId); + } + + return OkResult(share); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error exporting a share of channel {ChannelId}", channelId); + return ErrorResult("Could not export the share", ex); + } + } + + /// Imports a share published by another instance. + /// + /// The account joins the channel when it is not a member yet and the + /// share carries an invitation hash. Import runs in the background; the + /// imported files then appear under the shared collections and can be + /// downloaded with POST /api/v1/transfers/downloads using + /// sharedCollectionId. + /// + [HttpPost("import")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status202Accepted)] + public IActionResult Import([FromBody] ImportSharedRequest request) + { + if (request?.Share == null || string.IsNullOrWhiteSpace(request.Share.id)) + return BadRequestResult("A share payload with a channel id is required"); + + var progress = new GenericNotificationProgressModel(); + _ = Task.Run(async () => + { + try + { + await _files.importSharedData(request.Share, progress); + } + catch (Exception ex) + { + _logger.LogError(ex, "Background import of a share failed"); + } + }); + + return Accepted(ApiResult.Done("Share import started")); + } + + /// Deletes a shared collection from this server. + [HttpDelete("{id}")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public async Task Delete(string id) + { + try + { + var info = await _files.GetSharedInfoById(id); + if (info == null) + return NotFoundResult("Shared collection not found"); + + await _files.DeleteShared(id, info.CollectionId); + return OkEmpty("Shared collection deleted"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting shared collection {Id}", id); + return ErrorResult("Could not delete the shared collection", ex); + } + } + + /// Exports a channel folder as Emby/Kodi .strm files. + /// + /// Each .strm holds a URL that streams the file straight from + /// Telegram, so a media server can present the whole library without + /// storing anything. The URL flavour depends on + /// strmStreamingMode in the configuration. + /// + /// With destinationFolder the files are written under the server + /// local root; without it, the response carries a relative URL to a zip + /// archive. + /// + [HttpPost("strm")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task CreateStrm([FromQuery] string channelId, [FromBody] CreateStrmRequest request) + { + if (string.IsNullOrWhiteSpace(channelId)) + return BadRequestResult("A channel id is required"); + + request ??= new CreateStrmRequest(); + var host = string.IsNullOrWhiteSpace(request.Host) ? BaseUrl : request.Host; + var path = string.IsNullOrWhiteSpace(request.Path) ? "/" : request.Path; + + try + { + if (!string.IsNullOrWhiteSpace(request.DestinationFolder)) + { + await _files.CreateStrmFilesToLocal(path, channelId, host, request.DestinationFolder); + return OkResult(request.DestinationFolder, "STRM files written to the local storage"); + } + + var result = await _files.CreateStrmFiles(path, channelId, host); + return OkResult(result, "STRM archive created"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating STRM files for channel {ChannelId}", channelId); + return ErrorResult("Could not create the STRM files", ex); + } + } + + private static SharedCollectionDto ToDto(BsonSharedInfoModel m) => new() + { + Id = m.Id, + Name = m.Name, + Description = m.Description, + ChannelId = m.ChannelId, + CollectionId = m.CollectionId, + DateCreated = m.DateCreated, + DateModified = m.DateModified + }; + } +} diff --git a/TelegramDownloader/Controllers/Api/V1/SystemController.cs b/TelegramDownloader/Controllers/Api/V1/SystemController.cs new file mode 100644 index 0000000..284c486 --- /dev/null +++ b/TelegramDownloader/Controllers/Api/V1/SystemController.cs @@ -0,0 +1,321 @@ +using Microsoft.AspNetCore.Mvc; +using TelegramDownloader.Data; +using TelegramDownloader.Data.db; +using TelegramDownloader.Models; +using TelegramDownloader.Models.Api; +using TelegramDownloader.Services; + +namespace TelegramDownloader.Controllers.Api.V1 +{ + /// + /// Server health, resource usage, application logs and maintenance of the + /// channel index databases. + /// + [Route("api/v1/system")] + [Tags("System")] + public class SystemController : ApiV1ControllerBase + { + private readonly ITelegramService _telegram; + private readonly ISetupService _setup; + private readonly ISystemMetricsService _metrics; + private readonly ILogQueryService _logs; + private readonly IDbService _db; + private readonly ILogger _logger; + + public SystemController( + ITelegramService telegram, + ISetupService setup, + ISystemMetricsService metrics, + ILogQueryService logs, + IDbService db, + ILogger logger) + { + _telegram = telegram; + _setup = setup; + _metrics = metrics; + _logs = logs; + _db = db; + _logger = logger; + } + + /// Liveness probe. + /// + /// Always answers 200 when the process is up. Use it to verify + /// connectivity and, when an API key is configured, that the key works. + /// + [HttpGet("ping")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult Ping() => OkResult("pong"); + + /// Server identity, versions and readiness. + /// + /// The natural first call of a mobile client: it reports whether setup + /// is complete, whether a Telegram session is active, and the path of + /// the SignalR hub to connect to. + /// + [HttpGet("info")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task Info() + { + var dto = new ServerInfoDto + { + Version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown", + TelegramConfigured = _telegram.IsConfigured, + RequiresApiKey = !string.IsNullOrEmpty(GeneralConfigStatic.config?.MobileApiKey) + || !string.IsNullOrEmpty(GeneralConfigStatic.tlconfig?.mobile_api_key) + }; + + try + { + dto.TelegramAuthenticated = _telegram.IsConfigured && _telegram.checkUserLogin(); + } + catch + { + dto.TelegramAuthenticated = false; + } + + try + { + var status = await _setup.GetSetupStatusAsync(); + dto.SetupComplete = status.CurrentStep == SetupStep.Complete; + dto.MongoConnected = status.MongoDbConnected; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not read the setup status"); + } + + return OkResult(dto); + } + + /// Progress of the first-run wizard. + [HttpGet("setup")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task Setup() + { + try + { + var status = await _setup.GetSetupStatusAsync(); + return OkResult(new SetupStatusDto + { + CurrentStep = status.CurrentStep.ToString(), + MongoDbConfigured = status.MongoDbConfigured, + MongoDbConnected = status.MongoDbConnected, + TelegramConfigured = status.TelegramConfigured, + MongoDbError = status.MongoDbError + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading the setup status"); + return ErrorResult("Could not read the setup status", ex); + } + } + + /// CPU, memory and disk usage of the server. + [HttpGet("metrics")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task Metrics() + { + try + { + var metrics = await _metrics.GetMetricsAsync(); + return OkResult(SystemMetricsDto.From(metrics)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading system metrics"); + return ErrorResult("Could not read the system metrics", ex); + } + } + + /// Queries the application logs. + /// + /// Logs live in the TFM_Logs MongoDB database. When MongoDB is + /// not configured the endpoint answers 503. + /// + [HttpGet("logs")] + [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status503ServiceUnavailable)] + public async Task Logs([FromQuery] LogQuery query) + { + if (!_logs.IsInitialized) + return UnavailableResult("The log store is not available"); + + try + { + var result = await _logs.GetLogs(new LogQueryRequest + { + Page = query.Page, + PageSize = query.PageSize, + FromDate = query.FromDate, + ToDate = query.ToDate, + Level = query.Level, + Logger = query.Logger, + Version = query.Version, + SearchText = query.Search, + DescendingOrder = !query.SortDescending ? true : query.SortDescending + }); + + var items = (result.Logs ?? new List()).Select(l => new LogEntryDto + { + Id = l.Id ?? string.Empty, + Timestamp = l.Timestamp, + Level = l.Level, + Message = l.Message, + Logger = l.Logger, + Exception = l.Exception, + Version = l.Version + }).ToList(); + + return OkPaged(items, PageInfo.Create(result.Page, result.PageSize, result.TotalCount)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error querying the logs"); + return ErrorResult("Could not query the logs", ex); + } + } + + /// Distinct logger names present in the log store. + [HttpGet("logs/loggers")] + [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)] + public async Task LogLoggers() + { + if (!_logs.IsInitialized) return UnavailableResult("The log store is not available"); + return OkResult(await _logs.GetLoggerNames()); + } + + /// Application versions present in the log store. + [HttpGet("logs/versions")] + [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)] + public async Task LogVersions() + { + if (!_logs.IsInitialized) return UnavailableResult("The log store is not available"); + return OkResult(await _logs.GetVersions()); + } + + /// Deletes log records older than the given number of days. + [HttpDelete("logs")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task DeleteLogs([FromQuery] int daysToKeep = 30) + { + if (!_logs.IsInitialized) return UnavailableResult("The log store is not available"); + if (daysToKeep < 0) return BadRequestResult("daysToKeep cannot be negative"); + + var deleted = await _logs.DeleteOldLogs(daysToKeep); + return OkResult(deleted, $"{deleted} log entries deleted"); + } + + /// Lists the channel index databases and their size. + [HttpGet("databases")] + [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)] + public async Task Databases() + { + try + { + var names = await _db.GetAllChannelDatabaseNames() ?? new List(); + var result = new List(); + + foreach (var name in names) + { + var dto = new DatabaseStatsDto { ChannelId = name }; + try + { + var stats = await _db.GetDatabaseStats(name); + dto.SizeInBytes = stats.SizeInBytes; + dto.SizeText = HelperService.SizeSuffix(stats.SizeInBytes); + dto.DocumentCount = stats.DocumentCount; + dto.CreatedAt = stats.CreatedAt; + dto.LastModified = stats.LastModified; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not read stats of database {Name}", name); + } + + if (long.TryParse(name, out var channelId)) + { + try { dto.ChannelName = _telegram.getChatName(channelId); } + catch { /* the account may have left the channel */ } + } + + result.Add(dto); + } + + return OkResult(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing the channel databases"); + return ErrorResult("Could not list the channel databases", ex); + } + } + + /// Checks a channel index for broken folder paths. + /// + /// Older versions could store inconsistent FilterPath/FilterId + /// values, which shows up as folders that look empty. Analyse first, then + /// repair with the endpoint below. + /// + [HttpGet("databases/{channelId}/analysis")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task AnalyzeDatabase(string channelId) + { + try + { + var result = await _db.AnalyzeFilterPaths(channelId); + return OkResult(new PathAnalysisDto + { + DatabaseName = result.DatabaseName, + TotalItems = result.TotalItems, + ItemsWithIssues = result.ItemsWithIssues, + FilterPathIssues = result.FilterPathIssues, + FilterIdIssues = result.FilterIdIssues, + FilePathIssues = result.FilePathIssues, + HasIssues = result.HasIssues, + Error = result.Error + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error analysing database {ChannelId}", channelId); + return ErrorResult("Could not analyse the channel database", ex); + } + } + + /// Repairs the broken folder paths of a channel index. + [HttpPost("databases/{channelId}/repair")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task RepairDatabase(string channelId) + { + try + { + var repaired = await _db.RepairFilterPaths(channelId); + return OkResult(repaired, $"{repaired} entries repaired"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error repairing database {ChannelId}", channelId); + return ErrorResult("Could not repair the channel database", ex); + } + } + + /// Deletes persisted tasks that are older than the configured limit. + [HttpPost("maintenance/cleanup-tasks")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task CleanupTasks([FromServices] ITaskPersistenceService persistence) + { + try + { + await persistence.CleanupStaleTasks(); + return OkEmpty("Stale tasks cleaned up"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error cleaning up stale tasks"); + return ErrorResult("Could not clean up the stale tasks", ex); + } + } + } +} diff --git a/TelegramDownloader/Controllers/Api/V1/TransfersController.cs b/TelegramDownloader/Controllers/Api/V1/TransfersController.cs new file mode 100644 index 0000000..f946a19 --- /dev/null +++ b/TelegramDownloader/Controllers/Api/V1/TransfersController.cs @@ -0,0 +1,574 @@ +using Microsoft.AspNetCore.Mvc; +using Syncfusion.Blazor.FileManager; +using TelegramDownloader.Data; +using TelegramDownloader.Data.db; +using TelegramDownloader.Models; +using TelegramDownloader.Models.Api; +using TelegramDownloader.Services; +using TelegramDownloader.Services.Api; + +namespace TelegramDownloader.Controllers.Api.V1 +{ + /// + /// Everything that moves bytes: pulling files out of Telegram onto the + /// server, pushing server files into Telegram, and controlling the queue. + /// + /// These endpoints only enqueue work and return immediately. Progress is + /// published on the /hubs/transfers SignalR hub; the snapshot + /// endpoint below returns the very same payload for clients that prefer + /// polling or need an initial state. + /// + [Route("api/v1/transfers")] + [Tags("Transfers")] + public class TransfersController : ApiV1ControllerBase + { + private readonly TransactionInfoService _tis; + private readonly IFileService _files; + private readonly IDbService _db; + private readonly ITelegramService _telegram; + private readonly ITaskPersistenceService _persistence; + private readonly ILogger _logger; + + public TransfersController( + TransactionInfoService tis, + IFileService files, + IDbService db, + ITelegramService telegram, + ITaskPersistenceService persistence, + ILogger logger) + { + _tis = tis; + _files = files; + _db = db; + _telegram = telegram; + _persistence = persistence; + _logger = logger; + } + + /// Full snapshot of active and queued transfers. + /// + /// Identical payload to the TransfersSnapshot hub message. Prefer + /// the hub for live updates and use this once at startup, or when a + /// client reconnects and wants to resynchronise. + /// + [HttpGet] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult Snapshot() => OkResult(TransferSnapshotBuilder.BuildSnapshot(_tis)); + + /// Counters and current transfer speeds. + /// Identical payload to the TransferSummary hub message. + [HttpGet("summary")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult Summary() => OkResult(TransferSnapshotBuilder.BuildSummary(_tis)); + + /// Retained download/upload speed samples, for charts. + [HttpGet("speed-history")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult SpeedHistory() => OkResult(TransferSnapshotBuilder.BuildSpeedHistory(_tis)); + + /// Lists downloads. + /// List the queue instead of the running downloads. + /// Paging. + [HttpGet("downloads")] + [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)] + public IActionResult Downloads([FromQuery] bool queued = false, [FromQuery] PagedQuery? query = null) + { + query ??= new PagedQuery(); + var source = (queued ? _tis.pendingDownloadModels : _tis.downloadModels) + .ToList() + .Select(d => TransferDto.FromDownload(d, queued)) + .ToList(); + var (items, page) = Paginate(source, query); + return OkPaged(items, page); + } + + /// Lists uploads. + /// List the queue instead of the running uploads. + /// Paging. + [HttpGet("uploads")] + [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)] + public IActionResult Uploads([FromQuery] bool queued = false, [FromQuery] PagedQuery? query = null) + { + query ??= new PagedQuery(); + var source = (queued ? _tis.pendingUploadModels : _tis.uploadModels) + .ToList() + .Select(u => TransferDto.FromUpload(u, queued)) + .ToList(); + var (items, page) = Paginate(source, query); + return OkPaged(items, page); + } + + /// Lists batch tasks (a folder download or upload as a whole). + [HttpGet("tasks")] + [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)] + public IActionResult Tasks([FromQuery] PagedQuery? query = null) + { + query ??= new PagedQuery(); + var source = _tis.infoDownloadTaksModel + .ToList() + .OrderBy(t => t.creationDate) + .Select(TransferDto.FromBatch) + .ToList(); + var (items, page) = Paginate(source, query); + return OkPaged(items, page); + } + + /// Details of a single transfer, whatever its kind. + [HttpGet("{id}")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public IActionResult Get(string id) + { + if (!TransferSnapshotBuilder.TryFind(_tis, id, out var download, out var upload, out var task)) + return NotFoundResult("Transfer not found", ApiErrorCodes.TaskNotFound); + + if (download != null) + return OkResult(TransferDto.FromDownload(download, _tis.pendingDownloadModels.Contains(download))); + if (upload != null) + return OkResult(TransferDto.FromUpload(upload, _tis.pendingUploadModels.Contains(upload))); + return OkResult(TransferDto.FromBatch(task!)); + } + + /// Downloads channel files onto the server. + /// + /// Accepts file ids and folder ids; folders are pulled recursively. The + /// call returns as soon as the work is queued, and each file then shows + /// up as its own entry on the transfers hub. + /// + /// targetPath is relative to the server local root. When omitted, + /// the channel folder structure is reproduced under it. + /// + [HttpPost("downloads")] + [RequireTelegramSession] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status202Accepted)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status400BadRequest)] + public async Task StartDownload([FromBody] StartDownloadRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.ChannelId)) + return BadRequestResult("A channel id is required"); + if (request.FileIds == null || request.FileIds.Count == 0) + return BadRequestResult("At least one file id is required"); + + try + { + var dbName = string.IsNullOrEmpty(request.SharedCollectionId) + ? request.ChannelId + : DbService.SHARED_DB_NAME; + + var contents = new List(); + var skipped = new List(); + + foreach (var id in request.FileIds) + { + var entry = string.IsNullOrEmpty(request.SharedCollectionId) + ? await _db.getFileById(request.ChannelId, id) + : await _db.getFileById(dbName, id, request.SharedCollectionId); + + if (entry == null) skipped.Add(id); + else contents.Add(entry.toFileManagerContent()); + } + + if (contents.Count == 0) + return BadRequestResult("None of the supplied ids could be resolved", ApiErrorCodes.FileNotFound); + + var targetPath = string.IsNullOrWhiteSpace(request.TargetPath) ? null : request.TargetPath; + + // The download pipeline is long running; hand it off so the + // client is not blocked while files stream in. + _ = Task.Run(async () => + { + try + { + await _files.downloadFile( + dbName, + contents, + targetPath, + request.SharedCollectionId, + string.IsNullOrEmpty(request.SharedCollectionId) ? null : request.ChannelId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Background download from channel {ChannelId} failed", request.ChannelId); + } + }); + + return Accepted(ApiResult.Ok( + new TransferAcceptedDto { Accepted = contents.Count, Skipped = skipped }, + "Download queued")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error queuing a download from channel {ChannelId}", request.ChannelId); + return ErrorResult("Could not queue the download", ex); + } + } + + /// Uploads server files into a channel. + /// + /// localPaths are relative to the server local root; folders are + /// pushed recursively. The whole request becomes one batch task, visible + /// under tasks in the snapshot, which in turn spawns one upload + /// entry per file. + /// + [HttpPost("uploads")] + [RequireTelegramSession] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status202Accepted)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status400BadRequest)] + public async Task StartUpload([FromBody] StartUploadRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.ChannelId)) + return BadRequestResult("A channel id is required"); + if (request.LocalPaths == null || request.LocalPaths.Count == 0) + return BadRequestResult("At least one local path is required"); + + try + { + var contents = new List(); + var skipped = new List(); + + foreach (var relative in request.LocalPaths) + { + var content = BuildLocalContent(relative); + if (content == null) skipped.Add(relative); + else contents.Add(content); + } + + if (contents.Count == 0) + return BadRequestResult("None of the supplied paths exist under the local root", ApiErrorCodes.FileNotFound); + + var targetPath = ChannelFolderResolver.NormalizeFolderPath(request.TargetPath); + await _files.AddUploadFileFromServer(request.ChannelId, targetPath, contents); + + var task = _tis.infoDownloadTaksModel.LastOrDefault(t => t.isUpload); + return Accepted(ApiResult.Ok( + new TransferAcceptedDto + { + Accepted = contents.Count, + Skipped = skipped, + TaskId = task?._internalId + }, + "Upload queued")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error queuing an upload to channel {ChannelId}", request.ChannelId); + return ErrorResult("Could not queue the upload", ex); + } + } + + /// Downloads the media attached to raw Telegram messages. + /// + /// Works on any chat, indexed or not: this is how the web UI saves a + /// file straight from the message list. + /// + [HttpPost("messages")] + [RequireTelegramSession] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status202Accepted)] + public async Task DownloadMessages([FromBody] DownloadMessagesRequest request) + { + if (request == null || request.MessageIds == null || request.MessageIds.Count == 0) + return BadRequestResult("At least one message id is required"); + + var accepted = 0; + var skipped = new List(); + + foreach (var messageId in request.MessageIds) + { + try + { + var message = await _telegram.getMessageFile(request.ChatId.ToString(), messageId); + if (message == null) + { + skipped.Add(messageId.ToString()); + continue; + } + + var chatMessage = new ChatMessages { message = message, isDocument = true }; + _ = Task.Run(async () => + { + try + { + await _files.DownloadFileFromChat(chatMessage, null, request.TargetPath, null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Download of message {MessageId} failed", messageId); + } + }); + accepted++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not resolve message {MessageId} of chat {ChatId}", messageId, request.ChatId); + skipped.Add(messageId.ToString()); + } + } + + return Accepted(ApiResult.Ok( + new TransferAcceptedDto { Accepted = accepted, Skipped = skipped }, + "Message downloads queued")); + } + + /// Pauses the whole download queue. + /// + /// Running downloads are paused and pushed back to the front of the + /// queue, so resuming continues where they stopped. + /// + [HttpPost("downloads/pause")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult PauseDownloads() + { + _tis.PauseDownloads(); + return OkResult(TransferSnapshotBuilder.BuildSummary(_tis), "Downloads paused"); + } + + /// Resumes the download queue. + [HttpPost("downloads/resume")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult ResumeDownloads() + { + _tis.PlayDownloads(); + return OkResult(TransferSnapshotBuilder.BuildSummary(_tis), "Downloads resumed"); + } + + /// Stops every download and empties the queue. + [HttpPost("downloads/stop")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult StopDownloads() + { + _tis.StopDownloads(); + return OkResult(TransferSnapshotBuilder.BuildSummary(_tis), "Downloads stopped"); + } + + /// Cancels one transfer. + /// + /// Cancelling a batch task also cancels the individual downloads and + /// uploads it spawned. + /// + [HttpPost("{id}/cancel")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public IActionResult Cancel(string id) + { + if (!TransferSnapshotBuilder.TryFind(_tis, id, out var download, out var upload, out var task)) + return NotFoundResult("Transfer not found", ApiErrorCodes.TaskNotFound); + + download?.Cancel(); + upload?.Cancel(); + task?.cancelTask(); + return OkEmpty("Transfer cancelled"); + } + + /// Pauses one download. + [HttpPost("{id}/pause")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public IActionResult Pause(string id) + { + var download = _tis.downloadModels.FirstOrDefault(d => d._internalId == id); + if (download == null) + return NotFoundResult("No running download with that id", ApiErrorCodes.TaskNotFound); + + _tis.addToPendingDownloadList(download, atFirst: true, chekDownloads: false); + download.Pause(); + return OkEmpty("Download paused"); + } + + /// Retries a paused, cancelled or failed transfer. + [HttpPost("{id}/retry")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status404NotFound)] + public IActionResult Retry(string id) + { + if (!TransferSnapshotBuilder.TryFind(_tis, id, out var download, out _, out var task)) + return NotFoundResult("Transfer not found", ApiErrorCodes.TaskNotFound); + + if (task != null) + { + task.Retry(); + return OkEmpty("Task queued again"); + } + + if (download != null) + { + if (!_tis.pendingDownloadModels.Contains(download)) + _tis.addToPendingDownloadList(download, atFirst: true); + else + _ = _tis.CheckPendingDownloads(); + return OkEmpty("Download queued again"); + } + + return BadRequestResult("Only downloads and batch tasks can be retried", ApiErrorCodes.NotSupported); + } + + /// Removes finished entries (completed, cancelled and failed) from a list. + /// downloads, uploads, tasks or all. + [HttpPost("clear")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult Clear([FromQuery] string scope = "all") + { + switch (scope?.ToLowerInvariant()) + { + case "downloads": + _tis.clearDownloadCompleted(); + break; + case "uploads": + _tis.clearUploadCompleted(); + break; + case "tasks": + _tis.clearTasksCompleted(); + break; + case "all": + case null: + case "": + _tis.clearDownloadCompleted(); + _tis.clearUploadCompleted(); + _tis.clearTasksCompleted(); + break; + default: + return BadRequestResult("scope must be one of: downloads, uploads, tasks, all"); + } + + return OkResult(TransferSnapshotBuilder.BuildSnapshot(_tis), "Finished entries cleared"); + } + + /// Empties a queue without touching what is already running. + /// downloads, uploads or all. + [HttpPost("queue/clear")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult ClearQueue([FromQuery] string scope = "all") + { + switch (scope?.ToLowerInvariant()) + { + case "downloads": + _tis.ClearPendingDownloads(); + break; + case "uploads": + _tis.ClearPendingUploads(); + break; + default: + _tis.ClearPendingDownloads(); + _tis.ClearPendingUploads(); + break; + } + + return OkResult(TransferSnapshotBuilder.BuildSnapshot(_tis), "Queue cleared"); + } + + /// Lists the transfers persisted in MongoDB. + /// + /// Persisted transfers survive an application restart: on startup the + /// app reloads them and, when autoResumeOnStartup is enabled, + /// resumes them from the last confirmed byte. + /// + [HttpGet("persisted")] + [ProducesResponseType(typeof(ApiResult>), StatusCodes.Status200OK)] + public async Task Persisted([FromQuery] PagedQuery? query = null) + { + query ??= new PagedQuery(); + try + { + var tasks = await _persistence.LoadPendingTasks(); + var items = (tasks ?? new List()) + .Select(PersistedTaskDto.From) + .OrderByDescending(t => t.LastUpdated) + .ToList(); + var (page, info) = Paginate(items, query); + return OkPaged(page, info); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing persisted tasks"); + return ErrorResult("Could not list the persisted tasks", ex); + } + } + + /// Deletes one persisted transfer. + [HttpDelete("persisted/{internalId}")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task DeletePersisted(string internalId) + { + try + { + await _db.DeleteTask(internalId); + return OkEmpty("Persisted task deleted"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting persisted task {InternalId}", internalId); + return ErrorResult("Could not delete the persisted task", ex); + } + } + + /// Deletes every persisted transfer. + [HttpDelete("persisted")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public async Task ClearPersisted() + { + try + { + await _db.ClearAllTasks(); + return OkEmpty("Persisted tasks cleared"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error clearing persisted tasks"); + return ErrorResult("Could not clear the persisted tasks", ex); + } + } + + /// + /// Builds the descriptor the upload pipeline expects for a path under + /// the server local root. Returns null when the path escapes the root or + /// does not exist. + /// + private static FileManagerDirectoryContent? BuildLocalContent(string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) return null; + + var normalized = relativePath.Replace("\\", "/").TrimStart('/'); + var absolute = Path.GetFullPath(Path.Combine(FileService.LOCALDIR, normalized)); + var root = Path.GetFullPath(FileService.LOCALDIR); + + if (!absolute.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + return null; + + var parent = Path.GetDirectoryName(normalized)?.Replace("\\", "/") ?? string.Empty; + var filterPath = string.IsNullOrEmpty(parent) ? "/" : "/" + parent + "/"; + var name = Path.GetFileName(normalized); + + if (System.IO.File.Exists(absolute)) + { + var info = new System.IO.FileInfo(absolute); + return new FileManagerDirectoryContent + { + Name = name, + IsFile = true, + Size = info.Length, + FilterPath = filterPath, + Type = info.Extension, + DateModified = info.LastWriteTime, + DateCreated = info.CreationTime + }; + } + + if (Directory.Exists(absolute)) + { + var info = new DirectoryInfo(absolute); + return new FileManagerDirectoryContent + { + Name = name, + IsFile = false, + Size = 0, + HasChild = info.EnumerateFileSystemInfos().Any(), + FilterPath = filterPath, + Type = "folder", + DateModified = info.LastWriteTime, + DateCreated = info.CreationTime + }; + } + + return null; + } + } +} diff --git a/TelegramDownloader/Controllers/Mobile/MobileChannelController.cs b/TelegramDownloader/Controllers/Mobile/MobileChannelController.cs index c17c7f2..d16372a 100644 --- a/TelegramDownloader/Controllers/Mobile/MobileChannelController.cs +++ b/TelegramDownloader/Controllers/Mobile/MobileChannelController.cs @@ -46,13 +46,16 @@ public async Task GetAllChannels() { var chats = await _ts.getAllSavedChats(); var favorites = GeneralConfigStatic.config.FavouriteChannels ?? new List(); + var hidden = GeneralConfigStatic.config.HiddenChannels ?? new List(); + var showHidden = GeneralConfigStatic.config.ShowHiddenChannels; var dtos = new List(); foreach (var chat in chats) { + if (!showHidden && hidden.Contains(chat.chat.ID)) continue; var isFavorite = favorites.Contains(chat.chat.ID); var isOwner = _ts.isChannelOwner(chat.chat.ID); - var dto = ChannelDto.FromChatViewBase(chat, isFavorite, isOwner); + var dto = ChannelDto.FromChatViewBase(chat, isFavorite, isOwner, hidden.Contains(chat.chat.ID)); // Get file count for this channel try @@ -95,12 +98,14 @@ public async Task GetChannelsWithFolders() { var chatsWithFolders = await _ts.getChatsWithFolders(); var favorites = GeneralConfigStatic.config.FavouriteChannels ?? new List(); + var hidden = GeneralConfigStatic.config.HiddenChannels ?? new List(); + var showHidden = GeneralConfigStatic.config.ShowHiddenChannels; // Helper function to create ChannelDto with FileCount async Task CreateChannelDtoAsync(ChatViewBase chat, bool isFavorite) { var isOwner = _ts.isChannelOwner(chat.chat.ID); - var dto = ChannelDto.FromChatViewBase(chat, isFavorite, isOwner); + var dto = ChannelDto.FromChatViewBase(chat, isFavorite, isOwner, hidden.Contains(chat.chat.ID)); try { var files = await _db.getAllFilesInDirectoryById(chat.chat.ID.ToString(), null); @@ -130,10 +135,12 @@ async Task CreateChannelDtoAsync(ChatViewBase chat, bool isFavorite) { foreach (var chat in f.Chats) { + if (!showHidden && hidden.Contains(chat.chat.ID)) continue; var isFavorite = favorites.Contains(chat.chat.ID); folderDto.Channels.Add(await CreateChannelDtoAsync(chat, isFavorite)); } } + folderDto.ChannelCount = folderDto.Channels.Count; result.Folders.Add(folderDto); } @@ -144,6 +151,7 @@ async Task CreateChannelDtoAsync(ChatViewBase chat, bool isFavorite) { foreach (var chat in chatsWithFolders.UngroupedChats) { + if (!showHidden && hidden.Contains(chat.chat.ID)) continue; var isFavorite = favorites.Contains(chat.chat.ID); result.UngroupedChannels.Add(await CreateChannelDtoAsync(chat, isFavorite)); } diff --git a/TelegramDownloader/Controllers/WebDav/WebDavServerController.cs b/TelegramDownloader/Controllers/WebDav/WebDavServerController.cs new file mode 100644 index 0000000..e9da446 --- /dev/null +++ b/TelegramDownloader/Controllers/WebDav/WebDavServerController.cs @@ -0,0 +1,781 @@ +using System.Globalization; +using System.Text; + +using Microsoft.AspNetCore.Mvc; + +using Syncfusion.Blazor.FileManager; + +using TelegramDownloader.Data; +using TelegramDownloader.Data.db; +using TelegramDownloader.Models; +using TelegramDownloader.Services; +using TelegramDownloader.Services.Api; + +namespace TelegramDownloader.Controllers.WebDav +{ + /// + /// Native WebDAV endpoint (C# rewrite that replaces the Python proxy). + /// + /// Phase 1 implements the read side correctly β€” OPTIONS, PROPFIND, HEAD and + /// GET (full file as 200, or a byte range as 206) β€” reusing the disk-cached, + /// download-once streaming of . This + /// fixes the proxy's read bugs: truncated 6 MB GETs, no caching, PROPFIND + /// hiding single-child folders, and the always-206 behaviour. + /// + /// Phase 0 already added PUT (below), which reuses the regular + /// server->Telegram upload pipeline (single upload at a time, 2/4 GB split). + /// MKCOL/DELETE/MOVE/LOCK arrive in Phase 2. + /// + /// Mounted at /webdav/{channel}/{**path}. Paths map to the channel + /// index: a file resolves by FilePath, a directory lists children by + /// FilterPath (the same convention the file manager uses). + /// + [ApiController] + public class WebDavServerController : ControllerBase + { + private readonly IFileService _files; + private readonly IDbService _db; + private readonly ChannelFolderResolver _resolver; + private readonly IProgressiveDownloadService _progressiveDownload; + private readonly WebDavLockManager _locks; + private readonly ILogger _logger; + + // An upload can be slow: it may sit behind other uploads in the single + // upload queue and then transfer over Telegram. Cap how long a PUT waits. + private static readonly TimeSpan UploadTimeout = TimeSpan.FromMinutes(30); + + // How long a GET waits for the background cache download to create the file. + private static readonly TimeSpan FirstByteTimeout = TimeSpan.FromSeconds(30); + + private const string Dav = "DAV:"; + + public WebDavServerController( + IFileService files, + IDbService db, + ChannelFolderResolver resolver, + IProgressiveDownloadService progressiveDownload, + WebDavLockManager locks, + ILogger logger) + { + _files = files; + _db = db; + _resolver = resolver; + _progressiveDownload = progressiveDownload; + _locks = locks; + _logger = logger; + } + + // ---------------------------------------------------------------- OPTIONS + + [AcceptVerbs("OPTIONS")] + [Route("webdav/{**path}")] + public IActionResult Options(string? path) + { + Response.Headers["DAV"] = "1,2"; + Response.Headers["Allow"] = "OPTIONS, PROPFIND, HEAD, GET, PUT, MKCOL, DELETE, MOVE, LOCK, UNLOCK"; + Response.Headers["MS-Author-Via"] = "DAV"; + Response.ContentLength = 0; + return Ok(); + } + + // --------------------------------------------------------------- PROPFIND + + [AcceptVerbs("PROPFIND")] + [Route("webdav/{channel}/{**path}")] + public async Task PropFind(string channel, string? path) + { + if (!IsAuthorized()) return Challenge401(); + if (string.IsNullOrWhiteSpace(channel)) return BadRequest("channel is required"); + + var depth = Request.Headers["Depth"].ToString(); + if (string.IsNullOrEmpty(depth)) depth = "1"; + + var inner = NormalizeInner(path); // "/" or "/folder" or "/folder/file.ext" + var sb = new StringBuilder(); + sb.Append(""); + sb.Append($""); + + if (inner == "/") + { + // Channel root is always a collection. + AppendResponse(sb, channel, inner, channel, isDir: true, size: 0, type: null, modified: DateTime.UtcNow); + if (depth != "0") + foreach (var child in await _db.getAllFilesInDirectoryPath(channel, "/")) + AppendChild(sb, channel, "/", child); + } + else + { + var node = await _db.getFileByPath(channel, inner); + if (node == null) + { + sb.Clear(); + return NotFound(); + } + + if (node.IsFile) + { + AppendResponse(sb, channel, inner, node.Name, isDir: false, size: node.Size, type: node.Type, modified: node.DateModified); + } + else + { + AppendResponse(sb, channel, inner, node.Name, isDir: true, size: 0, type: null, modified: node.DateModified); + if (depth != "0") + foreach (var child in await _db.getAllFilesInDirectoryPath(channel, inner + "/")) + AppendChild(sb, channel, inner + "/", child); + } + } + + sb.Append(""); + return new ContentResult + { + Content = sb.ToString(), + ContentType = "application/xml; charset=utf-8", + StatusCode = StatusCodes.Status207MultiStatus + }; + } + + // ---------------------------------------------------------------- HEAD/GET + + [HttpHead] + [Route("webdav/{channel}/{**path}")] + public async Task Head(string channel, string? path) + { + if (!IsAuthorized()) return Challenge401(); + var node = await ResolveFile(channel, path); + if (node == null) return NotFound(); + + Response.Headers["Accept-Ranges"] = "bytes"; + Response.ContentType = FileService.getMimeType(node.Type); + Response.ContentLength = node.Size; + return new EmptyResult(); + } + + [HttpGet] + [Route("webdav/{channel}/{**path}")] + public async Task Get(string channel, string? path) + { + if (!IsAuthorized()) return Challenge401(); + var node = await ResolveFile(channel, path); + if (node == null) return NotFound(); + + var ct = HttpContext.RequestAborted; + long totalLength = node.Size; + var mimeType = FileService.getMimeType(node.Type); + + // ---- Parse Range (bytes=X-, bytes=X-Y, bytes=-N) ---- + long from = 0, to = totalLength - 1; + bool hasRange = false; + var rangeHeader = Request.Headers["Range"].ToString(); + if (!string.IsNullOrEmpty(rangeHeader) && rangeHeader.StartsWith("bytes=") && !rangeHeader.Contains(',')) + { + var parts = rangeHeader.Substring("bytes=".Length).Split('-'); + if (parts.Length == 2) + { + if (string.IsNullOrEmpty(parts[0])) + { + if (long.TryParse(parts[1], out var suffix) && suffix > 0) + { + from = Math.Max(0, totalLength - suffix); + to = totalLength - 1; + hasRange = true; + } + } + else if (long.TryParse(parts[0], out var f) && f >= 0) + { + from = f; + hasRange = true; + if (!string.IsNullOrEmpty(parts[1]) && long.TryParse(parts[1], out var t)) + to = Math.Min(t, totalLength - 1); + } + } + } + + if (hasRange && (from >= totalLength || to < from)) + { + Response.Headers["Content-Range"] = $"bytes */{totalLength}"; + return StatusCode(StatusCodes.Status416RangeNotSatisfiable); + } + + long length = totalLength == 0 ? 0 : to - from + 1; + + // ---- Locate / prime the download-once disk cache ---- + var cacheFileName = $"{channel}-{(node.MessageId != null ? node.MessageId.ToString() : node.Id)}-{node.Name}"; + var tempPath = Path.Combine(FileService.TEMPDIR, "_temp"); + Directory.CreateDirectory(tempPath); + var filePath = Path.Combine(tempPath, cacheFileName); + + bool fullyCached = System.IO.File.Exists(filePath) && new FileInfo(filePath).Length >= totalLength; + + if (!fullyCached && length > 0) + { + try + { + await _progressiveDownload.StartOrGetDownloadAsync(cacheFileName, channel, node, filePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "WebDAV GET: could not start cache download for {File}", node.Name); + } + + // Wait for the first bytes to hit disk before committing a status code, + // so a failure here can still return 503 instead of a half-written 200. + var deadline = DateTime.UtcNow.Add(FirstByteTimeout); + while (!System.IO.File.Exists(filePath)) + { + if (DateTime.UtcNow > deadline) + return StatusCode(StatusCodes.Status503ServiceUnavailable, "cache download did not start"); + try { await Task.Delay(100, ct); } catch (OperationCanceledException) { return new EmptyResult(); } + } + } + + // ---- Write status + headers ---- + Response.Headers["Accept-Ranges"] = "bytes"; + Response.ContentType = mimeType; + Response.Headers["Content-Disposition"] = $"attachment; filename=\"{Uri.EscapeDataString(node.Name)}\""; + Response.ContentLength = length; + if (hasRange) + { + Response.StatusCode = StatusCodes.Status206PartialContent; + Response.Headers["Content-Range"] = $"bytes {from}-{to}/{totalLength}"; + } + else + { + Response.StatusCode = StatusCodes.Status200OK; + } + + if (length == 0) return new EmptyResult(); + + // ---- Stream the bytes from the (possibly still growing) cache file ---- + try + { + await using var cache = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 64 * 1024, useAsync: true); + cache.Seek(from, SeekOrigin.Begin); + + long remaining = length; + var buffer = new byte[64 * 1024]; + while (remaining > 0 && !ct.IsCancellationRequested) + { + int toRead = (int)Math.Min(buffer.Length, remaining); + int n = await cache.ReadAsync(buffer, 0, toRead, ct); + if (n > 0) + { + await Response.Body.WriteAsync(buffer.AsMemory(0, n), ct); + remaining -= n; + continue; + } + + // At current EOF of the growing cache: wait if the download is still running. + var info = _progressiveDownload.GetDownloadInfo(cacheFileName); + if (info != null && info.IsDownloading) + { + await Task.Delay(100, ct); + continue; + } + + // Download stopped. Give the file one last chance (data may have flushed). + if (cache.Length > cache.Position) continue; + _logger.LogWarning("WebDAV GET: cache stopped short for {File} ({Remaining} bytes missing)", node.Name, remaining); + break; + } + + await Response.Body.FlushAsync(ct); + } + catch (OperationCanceledException) + { + _logger.LogDebug("WebDAV GET: client closed connection for {File}", node.Name); + } + + return new EmptyResult(); + } + + // ---------------------------------------------------------------- PUT + + [AcceptVerbs("PUT")] + [Route("webdav/{channel}/{**path}")] + [RequestSizeLimit(long.MaxValue)] + public async Task Put(string channel, string path) + { + if (!IsAuthorized()) return Challenge401(); + + if (string.IsNullOrWhiteSpace(channel) || string.IsNullOrWhiteSpace(path)) + return BadRequest("channel and path are required"); + + // Partial PUT (Content-Range) is not part of the WebDAV spec and the + // Telegram backend is append-only; reject it explicitly. + if (Request.Headers.ContainsKey("Content-Range")) + return StatusCode(StatusCodes.Status501NotImplemented, "partial PUT is not supported"); + + path = path.Replace('\\', '/').Trim('/'); + var lastSlash = path.LastIndexOf('/'); + var fileName = Path.GetFileName(path); + if (string.IsNullOrEmpty(fileName)) + return BadRequest("a file name is required"); + + // The destination folder MUST carry a trailing slash: the upload pipeline + // resolves the parent via `FilterPath + Name + "/"` and builds the child's + // FilePath with Path.Combine (which only stays forward-slashed when the base + // ends in a separator). Reuse the same normaliser the REST upload uses. + var folderRaw = lastSlash < 0 ? string.Empty : path.Substring(0, lastSlash); + var folder = ChannelFolderResolver.NormalizeFolderPath(folderRaw); + + var stagingRelative = $"{ApiUploadStaging.FolderName}/{Guid.NewGuid():N}"; + var stagingAbsolute = Path.Combine(FileService.LOCALDIR, stagingRelative.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(stagingAbsolute); + var stagedFile = Path.Combine(stagingAbsolute, fileName); + + try + { + long size; + await using (var fs = System.IO.File.Create(stagedFile)) + { + await Request.Body.CopyToAsync(fs, HttpContext.RequestAborted); + size = fs.Length; + } + + if (size == 0) + { + // Telegram can't store an empty message: represent 0-byte files as + // index-only nodes (no Telegram upload). The GET path already serves + // Size==0 with an empty body without touching Telegram. + TryCleanup(stagingAbsolute); + var innerPath = folder + fileName; // folder is normalized with a trailing slash + var existing = await _db.getFileByPath(channel, innerPath); + if (existing != null) + await _files.oneItemDeleteAsync(channel, ChannelFolderResolver.ToContent(existing)); + await _files.CreateEmptyFile(channel, folder, fileName); + _logger.LogInformation("WebDAV PUT created empty file {File} in channel {Channel}", fileName, channel); + return StatusCode(existing != null ? StatusCodes.Status204NoContent : StatusCodes.Status201Created); + } + + var content = new FileManagerDirectoryContent + { + Name = fileName, + IsFile = true, + Size = size, + FilterPath = "/" + stagingRelative + "/", + Type = Path.GetExtension(fileName) + }; + + // Pass our own task model so we can await THIS upload (no LastOrDefault race). + var task = new InfoDownloadTaksModel(); + await _files.AddUploadFileFromServer(channel, folder, + new List { content }, task); + + var terminal = await WaitForCompletionAsync(task, HttpContext.RequestAborted); + + switch (terminal) + { + case StateTask.Completed: + _logger.LogInformation("WebDAV PUT stored {File} ({Size} bytes) in channel {Channel}", fileName, size, channel); + return StatusCode(StatusCodes.Status201Created); + case StateTask.Error: + return StatusCode(StatusCodes.Status500InternalServerError, "upload failed"); + case StateTask.Canceled: + return StatusCode(StatusCodes.Status499ClientClosedRequest); + default: + _logger.LogWarning("WebDAV PUT timed out waiting for upload of {File} to channel {Channel}", fileName, channel); + return StatusCode(StatusCodes.Status504GatewayTimeout, "upload still in progress"); + } + } + catch (OperationCanceledException) + { + TryCleanup(stagingAbsolute); + return StatusCode(StatusCodes.Status499ClientClosedRequest); + } + catch (Exception ex) + { + _logger.LogError(ex, "WebDAV PUT failed for {File} in channel {Channel}", fileName, channel); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } + + // ---------------------------------------------------------------- MKCOL + + [AcceptVerbs("MKCOL")] + [Route("webdav/{channel}/{**path}")] + public async Task MkCol(string channel, string? path) + { + if (!IsAuthorized()) return Challenge401(); + + // A MKCOL request body is not defined by the spec. + if (Request.ContentLength.GetValueOrDefault() > 0) + return StatusCode(StatusCodes.Status415UnsupportedMediaType); + + var inner = NormalizeInner(path); + if (inner == "/") return StatusCode(StatusCodes.Status405MethodNotAllowed, "the root already exists"); + + if (await _db.getFileByPath(channel, inner) != null) + return StatusCode(StatusCodes.Status405MethodNotAllowed, "resource already exists"); + + var trimmed = inner.Trim('/'); + var idx = trimmed.LastIndexOf('/'); + var folderName = idx < 0 ? trimmed : trimmed.Substring(idx + 1); + var parentPath = idx < 0 ? "/" : "/" + trimmed.Substring(0, idx) + "/"; + + var parent = await _resolver.ResolveFolder(channel, null, parentPath); + if (parent == null || parent.IsFile) + return StatusCode(StatusCodes.Status409Conflict, "parent folder does not exist"); + + await _files.createFolder(channel, ChannelFolderResolver.CreateChildPath(parent), folderName, ChannelFolderResolver.ToContent(parent)); + return StatusCode(StatusCodes.Status201Created); + } + + // ---------------------------------------------------------------- DELETE + + [HttpDelete] + [Route("webdav/{channel}/{**path}")] + public async Task Delete(string channel, string? path) + { + if (!IsAuthorized()) return Challenge401(); + + var inner = NormalizeInner(path); + if (inner == "/") return StatusCode(StatusCodes.Status403Forbidden, "cannot delete the channel root"); + + var node = await _db.getFileByPath(channel, inner); + if (node == null) return NotFound(); + + await _files.oneItemDeleteAsync(channel, ChannelFolderResolver.ToContent(node)); + return NoContent(); + } + + // ---------------------------------------------------------------- MOVE + + [AcceptVerbs("MOVE")] + [Route("webdav/{channel}/{**path}")] + public async Task Move(string channel, string? path) + { + if (!IsAuthorized()) return Challenge401(); + + var inner = NormalizeInner(path); + if (inner == "/") return StatusCode(StatusCodes.Status403Forbidden, "cannot move the channel root"); + + var node = await _db.getFileByPath(channel, inner); + if (node == null) return NotFound(); + + var dest = ParseDestination(Request.Headers["Destination"].ToString()); + if (dest == null) return BadRequest("a valid Destination header is required"); + var (destChannel, destInner) = dest.Value; + if (destChannel != channel) + return StatusCode(StatusCodes.Status502BadGateway, "cross-channel MOVE is not supported"); + if (destInner == "/") return BadRequest("invalid destination"); + + var overwrite = Request.Headers["Overwrite"].ToString(); + var existing = await _db.getFileByPath(channel, destInner); + bool destExisted = existing != null; + if (destExisted) + { + if (string.Equals(overwrite, "F", StringComparison.OrdinalIgnoreCase)) + return StatusCode(StatusCodes.Status412PreconditionFailed); + await _files.oneItemDeleteAsync(channel, ChannelFolderResolver.ToContent(existing!)); + } + + // Split source and destination into (parent folder, name). + SplitPath(inner, out var srcParent, out var srcName); + SplitPath(destInner, out var dstParent, out var dstName); + + if (srcParent == dstParent) + { + // Same folder: pure rename (the common temp -> final case). + if (srcName != dstName) + await _db.updateName(channel, node.Id, dstName, srcName, node.IsFile, node.FilterPath); + } + else + { + var destParent = await _resolver.ResolveFolder(channel, null, dstParent); + if (destParent == null || destParent.IsFile) + return StatusCode(StatusCodes.Status409Conflict, "destination folder does not exist"); + + var destChildPath = ChannelFolderResolver.ChildFolderPath(destParent); + await _files.CopyOrMoveItems(channel, + new[] { ChannelFolderResolver.ToContent(node) }, + destChildPath, + ChannelFolderResolver.ToContent(destParent), + isCopy: false); + + if (srcName != dstName) + { + var moved = (await _db.getAllFilesInDirectoryPath(channel, destChildPath)) + .FirstOrDefault(x => x.Name == srcName); + if (moved != null) + await _db.updateName(channel, moved.Id, dstName, srcName, moved.IsFile, destChildPath); + } + } + + return destExisted ? NoContent() : StatusCode(StatusCodes.Status201Created); + } + + // ---------------------------------------------------------------- LOCK / UNLOCK + + [AcceptVerbs("LOCK")] + [Route("webdav/{channel}/{**path}")] + public async Task Lock(string channel, string? path) + { + if (!IsAuthorized()) return Challenge401(); + + var inner = NormalizeInner(path); + var key = channel + ":" + inner; + var timeout = _locks.ClampTimeout(ParseTimeoutHeader(Request.Headers["Timeout"].ToString())); + + string token; + // Refresh: a LOCK carrying the existing token in the If header (no body). + var ifToken = ExtractToken(Request.Headers["If"].ToString()); + if (!string.IsNullOrEmpty(ifToken) && _locks.Refresh(key, ifToken, timeout)) + { + token = ifToken; + } + else + { + var owner = await ReadOwnerFromBodyAsync(); + var acquired = _locks.TryAcquire(key, owner, timeout); + if (acquired == null) + return StatusCode(StatusCodes.Status423Locked); + token = acquired; + } + + var exists = inner == "/" || await _db.getFileByPath(channel, inner) != null; + Response.Headers["Lock-Token"] = "<" + token + ">"; + return new ContentResult + { + Content = BuildLockDiscoveryXml(channel, inner, token, timeout), + ContentType = "application/xml; charset=utf-8", + // 200 for an existing resource; 201 when the lock creates a lock-null resource. + StatusCode = exists ? StatusCodes.Status200OK : StatusCodes.Status201Created + }; + } + + [AcceptVerbs("UNLOCK")] + [Route("webdav/{channel}/{**path}")] + public IActionResult Unlock(string channel, string? path) + { + if (!IsAuthorized()) return Challenge401(); + + var token = ExtractToken(Request.Headers["Lock-Token"].ToString()); + if (string.IsNullOrEmpty(token)) + return BadRequest("a Lock-Token header is required"); + + _locks.Release(channel + ":" + NormalizeInner(path), token); + return NoContent(); // lenient: 204 even if the token was unknown/expired + } + + // ---------------------------------------------------------------- helpers + + /// Resolves a WebDAV path to a file node, or null if missing / a directory. + private async Task ResolveFile(string channel, string? path) + { + if (string.IsNullOrWhiteSpace(channel)) return null; + var inner = NormalizeInner(path); + if (inner == "/") return null; // the root is a collection, not a file + var node = await _db.getFileByPath(channel, inner); + return (node != null && node.IsFile) ? node : null; + } + + /// WebDAV sub-path β†’ index path ("/", "/folder", "/folder/file.ext"). + private static string NormalizeInner(string? path) + { + var p = (path ?? string.Empty).Replace('\\', '/').Trim('/'); + return p.Length == 0 ? "/" : "/" + p; + } + + /// Splits an index path into its parent folder path ("/", "/a/") and leaf name. + private static void SplitPath(string inner, out string parent, out string name) + { + var trimmed = inner.Trim('/'); + var idx = trimmed.LastIndexOf('/'); + name = idx < 0 ? trimmed : trimmed.Substring(idx + 1); + parent = idx < 0 ? "/" : "/" + trimmed.Substring(0, idx) + "/"; + } + + /// Parses a WebDAV Destination header into (channel, inner path), or null if invalid. + private static (string Channel, string Inner)? ParseDestination(string destination) + { + if (string.IsNullOrWhiteSpace(destination)) return null; + + string absPath; + if (Uri.TryCreate(destination, UriKind.Absolute, out var abs)) + absPath = abs.AbsolutePath; + else + absPath = destination; + + absPath = Uri.UnescapeDataString(absPath); + const string prefix = "/webdav/"; + var i = absPath.IndexOf(prefix, StringComparison.OrdinalIgnoreCase); + if (i < 0) return null; + + var rest = absPath.Substring(i + prefix.Length).Trim('/'); + if (rest.Length == 0) return null; + + var slash = rest.IndexOf('/'); + if (slash < 0) return (rest, "/"); + return (rest.Substring(0, slash), "/" + rest.Substring(slash + 1).Trim('/')); + } + + private void AppendChild(StringBuilder sb, string channel, string parentInner, BsonFileManagerModel child) + { + var childInner = (parentInner == "/" ? "/" : parentInner) + child.Name; + AppendResponse(sb, channel, childInner, child.Name, !child.IsFile, child.Size, child.Type, child.DateModified); + } + + private void AppendResponse(StringBuilder sb, string channel, string inner, string displayName, bool isDir, long size, string? type, DateTime modified) + { + var href = BuildHref(channel, inner, isDir); + sb.Append(""); + sb.Append($"{XmlEscape(href)}"); + sb.Append(""); + sb.Append($"{XmlEscape(displayName)}"); + if (isDir) + { + sb.Append(""); + } + else + { + sb.Append(""); + sb.Append($"{size}"); + sb.Append($"{XmlEscape(FileService.getMimeType(type))}"); + } + sb.Append($"{modified.ToUniversalTime().ToString("R", CultureInfo.InvariantCulture)}"); + sb.Append($"{modified.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)}"); + sb.Append("HTTP/1.1 200 OK"); + sb.Append(""); + } + + /// Builds a URL-encoded absolute href under /webdav/{channel}/... . + private static string BuildHref(string channel, string inner, bool isDir) + { + var sb = new StringBuilder("/webdav/"); + sb.Append(Uri.EscapeDataString(channel)); + foreach (var seg in inner.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + sb.Append('/'); + sb.Append(Uri.EscapeDataString(seg)); + } + if (isDir) sb.Append('/'); + return sb.ToString(); + } + + private static string XmlEscape(string? s) => + System.Security.SecurityElement.Escape(s ?? string.Empty) ?? string.Empty; + + /// Parses a WebDAV Timeout header ("Second-3600", "Infinite", CSV) into a TimeSpan. + private static TimeSpan? ParseTimeoutHeader(string? header) + { + if (string.IsNullOrWhiteSpace(header)) return null; + foreach (var raw in header.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (raw.StartsWith("Second-", StringComparison.OrdinalIgnoreCase) && + long.TryParse(raw.AsSpan("Second-".Length), out var secs) && secs > 0) + return TimeSpan.FromSeconds(secs); + // "Infinite" (or anything else) falls through to the clamped default. + } + return null; + } + + /// Extracts the first opaquelocktoken from an If / Lock-Token header value. + private static string ExtractToken(string? header) + { + if (string.IsNullOrWhiteSpace(header)) return string.Empty; + var m = System.Text.RegularExpressions.Regex.Match(header, @"opaquelocktoken:[^>)\s]+"); + return m.Success ? m.Value : string.Empty; + } + + /// Best-effort extraction of the <owner> element from a LOCK request body. + private async Task ReadOwnerFromBodyAsync() + { + try + { + using var reader = new StreamReader(Request.Body); + var body = await reader.ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(body)) return null; + var m = System.Text.RegularExpressions.Regex.Match( + body, @"<(?:\w+:)?owner>(.*?)", + System.Text.RegularExpressions.RegexOptions.Singleline); + return m.Success ? m.Groups[1].Value.Trim() : null; + } + catch + { + return null; + } + } + + private string BuildLockDiscoveryXml(string channel, string inner, string token, TimeSpan timeout) + { + var href = BuildHref(channel, inner, isDir: inner == "/"); + var sb = new StringBuilder(); + sb.Append(""); + sb.Append($""); + sb.Append(""); + sb.Append(""); + sb.Append("infinity"); + sb.Append($"Second-{(int)timeout.TotalSeconds}"); + sb.Append($"{XmlEscape(token)}"); + sb.Append($"{XmlEscape(href)}"); + sb.Append(""); + return sb.ToString(); + } + + private static async Task WaitForCompletionAsync(InfoDownloadTaksModel task, CancellationToken ct) + { + var deadline = DateTime.UtcNow.Add(UploadTimeout); + while (DateTime.UtcNow < deadline) + { + switch (task.state) + { + case StateTask.Completed: + case StateTask.Error: + case StateTask.Canceled: + return task.state; + } + await Task.Delay(250, ct); + } + return StateTask.Working; // timeout sentinel + } + + private bool IsAuthorized() + { + // Prefer credentials managed from the Config UI (persisted in Mongo); + // fall back to config.json as a whole pair for backward compatibility. + string? user; + string pass; + if (!string.IsNullOrEmpty(GeneralConfigStatic.config?.WebDavUser)) + { + user = GeneralConfigStatic.config.WebDavUser; + pass = GeneralConfigStatic.config.WebDavPassword ?? ""; + } + else + { + user = GeneralConfigStatic.tlconfig?.webdav_user; + pass = GeneralConfigStatic.tlconfig?.webdav_password ?? ""; + } + + // No credentials configured => open (dev mode), mirroring ApiKeyMiddleware. + if (string.IsNullOrEmpty(user)) + return true; + + var header = Request.Headers["Authorization"].ToString(); + if (!header.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) + return false; + + try + { + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(header.Substring("Basic ".Length).Trim())); + var sep = decoded.IndexOf(':'); + if (sep < 0) return false; + return decoded.Substring(0, sep) == user && decoded.Substring(sep + 1) == pass; + } + catch + { + return false; + } + } + + private IActionResult Challenge401() + { + Response.Headers["WWW-Authenticate"] = "Basic realm=\"TFM WebDAV\""; + return Unauthorized(); + } + + private static void TryCleanup(string dir) + { + try { if (Directory.Exists(dir)) Directory.Delete(dir, true); } catch { /* best effort */ } + } + } +} diff --git a/TelegramDownloader/Controllers/WebDavController.cs b/TelegramDownloader/Controllers/WebDavController.cs deleted file mode 100644 index 7348db9..0000000 --- a/TelegramDownloader/Controllers/WebDavController.cs +++ /dev/null @@ -1,83 +0,0 @@ -ο»Ώusing Microsoft.AspNetCore.Mvc; -using TelegramDownloader.Data.db; -using TelegramDownloader.Data; -using TelegramDownloader.Models; -using System.Text.Json; - -namespace TelegramDownloader.Controllers -{ - [Route("api/nodes")] - [ApiController] - public class WebDavController : ControllerBase - { - IDbService _db { get; set; } - private readonly ILogger _logger; - - public WebDavController(IDbService db, ILogger logger) - { - _db = db; - _logger = logger; - } - [HttpGet] - public async Task> webDavPaths([FromQuery] string path, [FromQuery] string depth) - { - _logger.LogDebug("WebDav request - Path: {Path}, Depth: {Depth}", path, depth); - var isFile = Path.HasExtension(path); - if (!path.EndsWith("/") && !isFile) - path = path + "/"; - if (String.IsNullOrEmpty(path)) - throw new BadHttpRequestException("Path is null or empty"); - string channel = path.Split("/")[1]; - if (String.IsNullOrEmpty(channel)) - throw new BadHttpRequestException("Channel is empty"); - path = path.Remove(0, path.IndexOf("/", 1)); - List files = new List(); - if (isFile) - { - var bsonFile = await _db.getFileByPath(channel, path); - if (!(bsonFile == null) && bsonFile.IsFile) - { - WebDavFileModel file = bsonFile.toWebDavFileModel(channel); - files = new List(); - files.Add(file); - } - } - if ((!isFile) || files.Count == 0) - { - if (depth == "0") - { - var bsonFile = await _db.getFileByPath(channel, path[..^1]); - if (bsonFile != null) - { - WebDavFileModel file = bsonFile.toWebDavFileModel(channel); - files = new List(); - files.Add(file); - } - - } - else - files = (await _db.getAllFilesInDirectoryPath(channel, path)).Select(file => file.toWebDavFileModel()).ToList(); - } - - // Console.WriteLine(JsonSerializer.Serialize(files)); - return files; - } - - [HttpGet("meta")] - public async Task webDavMetadata([FromQuery] string path) - { - _logger.LogDebug("WebDav metadata request - Path: {Path}", path); - if (String.IsNullOrEmpty(path)) - throw new Exception("Path is null or empty"); - string channel = path.Split("/")[1]; - if (String.IsNullOrEmpty(channel)) - throw new Exception("Channel is empty"); - path = path.Remove(0, path.IndexOf("/", 1)); - var bsonFile = await _db.getFileByPath(channel, path); - if (bsonFile == null || !bsonFile.IsFile) - throw new FileNotFoundException(); - WebDavFileModel files = bsonFile.toWebDavFileModel(channel); - return files; - } - } -} diff --git a/TelegramDownloader/Data/FileService.cs b/TelegramDownloader/Data/FileService.cs index 28695d7..3395982 100644 --- a/TelegramDownloader/Data/FileService.cs +++ b/TelegramDownloader/Data/FileService.cs @@ -352,7 +352,7 @@ public async Task> ShareFile(string dbName, string bs } else { - if (isMyChannel && !await _db.existItemByTelegramId(dbName, (int)child.MessageId)) + if (isMyChannel && child.MessageId.HasValue && !await _db.existItemByTelegramId(dbName, (int)child.MessageId)) await _ts.deleteFile(dbName, (int)child.MessageId); } } @@ -377,7 +377,7 @@ public async Task> ShareFile(string dbName, string bs } else { - if (isMyChannel && !await _db.existItemByTelegramId(dbName, (int)entry.MessageId)) + if (isMyChannel && entry.MessageId.HasValue && !await _db.existItemByTelegramId(dbName, (int)entry.MessageId)) await _ts.deleteFile(dbName, (int)entry.MessageId); } } @@ -417,7 +417,7 @@ private async Task itemDeleteAsync(string dbName, string filterPath, string name await _ts.deleteFile(dbName, id); } } - else + else if (child.MessageId.HasValue) { if (!await _db.existItemByTelegramId(dbName, (int)child.MessageId)) await _ts.deleteFile(dbName, (int)child.MessageId); @@ -442,7 +442,7 @@ private async Task itemDeleteAsync(string dbName, string filterPath, string name await _ts.deleteFile(dbName, id); } } - else + else if (entry.MessageId.HasValue) { if (!await _db.existItemByTelegramId(dbName, (int)entry.MessageId)) await _ts.deleteFile(dbName, (int)entry.MessageId); @@ -477,7 +477,7 @@ public async Task oneItemDeleteAsync(string dbName, Syncfusion.Blazor.FileManage await _ts.deleteFile(dbName, id); } } - else + else if (child.MessageId.HasValue) { if (!await _db.existItemByTelegramId(dbName, (int)child.MessageId)) await _ts.deleteFile(dbName, (int)child.MessageId); @@ -499,7 +499,7 @@ public async Task oneItemDeleteAsync(string dbName, Syncfusion.Blazor.FileManage await _ts.deleteFile(dbName, id); } } - else + else if (entry.MessageId.HasValue) { if (!await _db.existItemByTelegramId(dbName, (int)entry.MessageId)) await _ts.deleteFile(dbName, (int)entry.MessageId); @@ -1198,7 +1198,9 @@ public async Task AddUploadFileFromServer(string dbName, string currentPath, Lis { _logger.LogInformation("Adding upload task from server - DbName: {DbName}, Path: {Path}, FilesCount: {Count}", dbName, currentPath, files.Count); - idt = new InfoDownloadTaksModel(); + // Reuse a caller-supplied task model when provided (e.g. the WebDAV PUT + // handler that needs to await this exact upload); otherwise create one. + idt ??= new InfoDownloadTaksModel(); idt.tis = _tis; idt.id = Guid.NewGuid().ToString(); idt.total = 0; @@ -1252,6 +1254,43 @@ public async Task AddUploadFileFromServer(string dbName, string currentPath, Lis _tis.CheckPendingUploadInfoTasks(); } + /// + /// Creates a zero-byte file as an index-only node (no Telegram message), + /// since Telegram cannot store empty files. Mirrors the FilterPath/FilterId/ + /// FilePath computation used by the regular upload so the entry is consistent. + /// must be a normalized folder path with a + /// trailing slash (e.g. "/" or "/backups/"). + /// + public async Task CreateEmptyFile(string dbName, string currentPath, string fileName) + { + BsonFileManagerModel parent = await _db.getParentDirectoryByPath(dbName, currentPath) + ?? await _db.getRootFolder(dbName); + + var model = new BsonFileManagerModel + { + Name = fileName, + IsFile = true, + HasChild = false, + DateCreated = DateTime.Now, + DateModified = DateTime.Now, + FilterPath = parent.FilterPath == "" ? "/" : string.Concat(parent.FilterPath, parent.Name, "/"), + FilterId = string.Concat(parent.FilterId, parent.Id.ToString(), "/"), + ParentId = parent.Id, + FilePath = System.IO.Path.Combine(currentPath, fileName), + Type = System.IO.Path.GetExtension(fileName), + Size = 0, + MessageId = null, + isSplit = false + }; + + if (await _db.getFileByPath(dbName, System.IO.Path.Combine(currentPath, fileName)) == null) + { + await _db.createEntry(dbName, model); + if (!parent.HasChild) + await _db.setDirectoryHasChild(dbName, parent.Id); + } + } + /// /// Builds the URL written inside a .strm file according to the configured streaming mode. /// Files smaller than MaxPreloadFileSizeInMb are always fully preloaded. diff --git a/TelegramDownloader/Data/IFileService.cs b/TelegramDownloader/Data/IFileService.cs index cee27cd..c78bdbc 100644 --- a/TelegramDownloader/Data/IFileService.cs +++ b/TelegramDownloader/Data/IFileService.cs @@ -41,6 +41,7 @@ public interface IFileService Task UploadFile(string dbName, string currentPath, UploadFiles file); Task UploadFileFromServer(string dbName, string currentPath, List files, InfoDownloadTaksModel dm = null); Task AddUploadFileFromServer(string dbName, string currentPath, List files, InfoDownloadTaksModel idt = null); + Task CreateEmptyFile(string dbName, string currentPath, string fileName); Task refreshChannelFIles(string channelId, bool force = false, RefreshChannelOptions? refreshOptions = null); bool isChannelRefreshing(string channelId); Task PreloadFilesToTemp(string channelId, List items); diff --git a/TelegramDownloader/Data/ITelegramService.cs b/TelegramDownloader/Data/ITelegramService.cs index fc0b05b..8e0d060 100644 --- a/TelegramDownloader/Data/ITelegramService.cs +++ b/TelegramDownloader/Data/ITelegramService.cs @@ -28,6 +28,9 @@ public interface ITelegramService Task> GetFouriteChannels(bool mustRefresh = true); Task AddFavouriteChannel(long id); Task RemoveFavouriteChannel(long id); + Task AddHiddenChannel(long id); + Task RemoveHiddenChannel(long id); + Task> GetHiddenChannels(); Task> getAllChats(); Task> getAllSavedChats(); Task getChatsWithFolders(); diff --git a/TelegramDownloader/Data/TelegramService.cs b/TelegramDownloader/Data/TelegramService.cs index d115e7e..24f4daa 100644 --- a/TelegramDownloader/Data/TelegramService.cs +++ b/TelegramDownloader/Data/TelegramService.cs @@ -1531,6 +1531,30 @@ public async Task RemoveFavouriteChannel(long id) await GetFouriteChannels(); } } + + public async Task AddHiddenChannel(long id) + { + if (!GeneralConfigStatic.config.HiddenChannels.Contains(id)) + { + GeneralConfigStatic.AddHiddenChannel(id); + await GeneralConfigStatic.SaveChanges(_db, GeneralConfigStatic.config); + } + } + + public async Task RemoveHiddenChannel(long id) + { + if (GeneralConfigStatic.config.HiddenChannels.Contains(id)) + { + GeneralConfigStatic.DeleteHiddenChannel(id); + await GeneralConfigStatic.SaveChanges(_db, GeneralConfigStatic.config); + } + } + + public async Task> GetHiddenChannels() + { + var hidden = GeneralConfigStatic.config.HiddenChannels ?? new List(); + return (await getAllSavedChats()).Where(x => hidden.Contains(x.chat.ID)).ToList(); + } public async Task DownloadFileStream(Message message, long offset, int limit) { _logger.LogDebug("DownloadFileStream - Offset: {Offset}, Limit: {Limit}", offset, limit); diff --git a/TelegramDownloader/Dockerfile b/TelegramDownloader/Dockerfile index 6fb01b6..ad49c76 100644 --- a/TelegramDownloader/Dockerfile +++ b/TelegramDownloader/Dockerfile @@ -36,19 +36,5 @@ RUN dotnet publish "./TelegramDownloader.csproj" -c $BUILD_CONFIGURATION -o /app FROM base AS final WORKDIR /app -# Install Python (Alpine uses apk instead of apt) -RUN apk add --no-cache python3 py3-pip && \ - ln -sf /usr/bin/python3 /usr/bin/python - -COPY ./WebDav /app/WebDav - -# Create venv and install dependencies in single layer -RUN python3 -m venv /app/venv && \ - /app/venv/bin/pip install --no-cache-dir --upgrade pip && \ - /app/venv/bin/pip install --no-cache-dir -r /app/WebDav/requirements.txt && \ - /app/venv/bin/python -c "import uvicorn; print('uvicorn OK', uvicorn.__version__)" - -ENV PATH="/app/venv/bin:${PATH}" - COPY --from=publish /app/publish . ENTRYPOINT ["dotnet", "TelegramDownloader.dll"] \ No newline at end of file diff --git a/TelegramDownloader/Hubs/TransferHub.cs b/TelegramDownloader/Hubs/TransferHub.cs new file mode 100644 index 0000000..be9760e --- /dev/null +++ b/TelegramDownloader/Hubs/TransferHub.cs @@ -0,0 +1,89 @@ +using Microsoft.AspNetCore.SignalR; +using TelegramDownloader.Models.Api; +using TelegramDownloader.Services; +using TelegramDownloader.Services.Api; + +namespace TelegramDownloader.Hubs +{ + /// + /// Real-time channel for download/upload progress, mapped at /hubs/transfers. + /// + /// Server to client messages: + /// + /// TransfersSnapshot () - full state, sent on connect and whenever transfers change. + /// TransferSummary () - counters and speeds, sent more frequently than the snapshot. + /// SpeedHistoryPoint (, ) - one download and one upload sample, every few seconds. + /// + /// + /// Client to server methods are declared below and can be invoked at any time. + /// + public class TransferHub : Hub + { + /// Name of the message carrying a full snapshot. + public const string SnapshotMessage = "TransfersSnapshot"; + + /// Name of the message carrying counters and speeds. + public const string SummaryMessage = "TransferSummary"; + + /// Name of the message carrying one speed-history sample. + public const string SpeedPointMessage = "SpeedHistoryPoint"; + + /// Group receiving snapshot messages. + public const string SnapshotGroup = "transfers.snapshot"; + + /// Group receiving summary messages. + public const string SummaryGroup = "transfers.summary"; + + /// Group receiving speed-history samples. + public const string SpeedGroup = "transfers.speed"; + + private readonly TransactionInfoService _tis; + + public TransferHub(TransactionInfoService tis) + { + _tis = tis; + } + + /// + /// New clients join every group by default and immediately receive a + /// snapshot, so a mobile app can render the transfer list without an + /// extra REST round-trip. + /// + public override async Task OnConnectedAsync() + { + await Groups.AddToGroupAsync(Context.ConnectionId, SnapshotGroup); + await Groups.AddToGroupAsync(Context.ConnectionId, SummaryGroup); + await Groups.AddToGroupAsync(Context.ConnectionId, SpeedGroup); + await Clients.Caller.SendAsync(SnapshotMessage, TransferSnapshotBuilder.BuildSnapshot(_tis)); + await base.OnConnectedAsync(); + } + + /// Returns the current snapshot on demand. + public TransfersSnapshotDto GetSnapshot() => TransferSnapshotBuilder.BuildSnapshot(_tis); + + /// Returns the current counters and speeds on demand. + public TransferSummaryDto GetSummary() => TransferSnapshotBuilder.BuildSummary(_tis); + + /// Returns the retained speed history on demand. + public SpeedHistoryDto GetSpeedHistory() => TransferSnapshotBuilder.BuildSpeedHistory(_tis); + + /// + /// Stops receiving full snapshots while still receiving summaries. Useful + /// for a background app that only needs a progress badge. + /// + public Task MuteSnapshots() => Groups.RemoveFromGroupAsync(Context.ConnectionId, SnapshotGroup); + + /// Resumes full snapshot delivery and pushes one immediately. + public async Task UnmuteSnapshots() + { + await Groups.AddToGroupAsync(Context.ConnectionId, SnapshotGroup); + await Clients.Caller.SendAsync(SnapshotMessage, TransferSnapshotBuilder.BuildSnapshot(_tis)); + } + + /// Stops receiving speed-history samples. + public Task MuteSpeedHistory() => Groups.RemoveFromGroupAsync(Context.ConnectionId, SpeedGroup); + + /// Resumes speed-history samples. + public Task UnmuteSpeedHistory() => Groups.AddToGroupAsync(Context.ConnectionId, SpeedGroup); + } +} diff --git a/TelegramDownloader/Middleware/ApiKeyMiddleware.cs b/TelegramDownloader/Middleware/ApiKeyMiddleware.cs index 93f9dee..58c8b6d 100644 --- a/TelegramDownloader/Middleware/ApiKeyMiddleware.cs +++ b/TelegramDownloader/Middleware/ApiKeyMiddleware.cs @@ -17,12 +17,26 @@ public ApiKeyMiddleware(RequestDelegate next, ILogger logger) _logger = logger; } + /// + /// Path prefixes protected by the API key: the legacy mobile API, the + /// modular v1 API and the SignalR hubs it exposes. + /// + private static readonly string[] PROTECTED_PREFIXES = + { + "/api/mobile", + "/api/v1", + "/hubs" + }; + public async Task InvokeAsync(HttpContext context) { - // Only check API key for mobile API endpoints - if (context.Request.Path.StartsWithSegments("/api/mobile")) + if (PROTECTED_PREFIXES.Any(p => context.Request.Path.StartsWithSegments(p))) { - var configuredApiKey = GeneralConfigStatic.tlconfig?.mobile_api_key; + // Prefer the value managed from the Config UI (persisted in Mongo); + // fall back to config.json for backward compatibility. + var configuredApiKey = !string.IsNullOrEmpty(GeneralConfigStatic.config?.MobileApiKey) + ? GeneralConfigStatic.config.MobileApiKey + : GeneralConfigStatic.tlconfig?.mobile_api_key; // If no API key is configured, allow all requests (development mode) if (string.IsNullOrEmpty(configuredApiKey)) @@ -44,36 +58,40 @@ public async Task InvokeAsync(HttpContext context) { providedApiKey = queryApiKey; } + else if (context.Request.Query.TryGetValue("access_token", out var accessToken)) + { + // SignalR clients cannot set custom headers on the WebSocket + // handshake, so they pass the key through the standard + // access_token query parameter. + providedApiKey = accessToken; + } + else if (context.Request.Headers.TryGetValue("Authorization", out var authHeader)) + { + // The SignalR JS/.NET clients send the access token as a + // Bearer header on the negotiate request (only the socket + // itself falls back to the query string). + var value = authHeader.ToString(); + if (value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + providedApiKey = value["Bearer ".Length..].Trim(); + } if (string.IsNullOrEmpty(providedApiKey)) { - _logger.LogWarning("Mobile API request without API key from {IP}", + _logger.LogWarning("API request without API key from {IP}", context.Connection.RemoteIpAddress); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - context.Response.ContentType = "application/json"; - await context.Response.WriteAsJsonAsync(new - { - success = false, - error = "API key required", - message = $"Please provide your API key in the {API_KEY_HEADER} header or apiKey query parameter" - }); + await WriteUnauthorized(context, "API key required", + $"Provide your API key in the {API_KEY_HEADER} header, or in the apiKey/access_token query parameter"); return; } // Validate API key if (!configuredApiKey.Equals(providedApiKey, StringComparison.Ordinal)) { - _logger.LogWarning("Invalid mobile API key attempt from {IP}", + _logger.LogWarning("Invalid API key attempt from {IP}", context.Connection.RemoteIpAddress); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - context.Response.ContentType = "application/json"; - await context.Response.WriteAsJsonAsync(new - { - success = false, - error = "Invalid API key" - }); + await WriteUnauthorized(context, "Invalid API key", null); return; } @@ -82,6 +100,40 @@ await context.Response.WriteAsJsonAsync(new await _next(context); } + + /// + /// Writes the 401 body. The v1 API and the hubs use the v1 envelope + /// (error is an object with a machine-readable code); + /// /api/mobile keeps its original flat shape so the existing + /// audio app is not broken. + /// + private static async Task WriteUnauthorized(HttpContext context, string error, string? detail) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.ContentType = "application/json"; + + if (context.Request.Path.StartsWithSegments("/api/mobile")) + { + await context.Response.WriteAsJsonAsync(new + { + success = false, + error, + message = detail + }); + return; + } + + await context.Response.WriteAsJsonAsync(new + { + success = false, + error = new + { + code = Models.Api.ApiErrorCodes.Unauthorized, + message = error, + detail + } + }); + } } /// diff --git a/TelegramDownloader/Models/Api/ApiEnvelope.cs b/TelegramDownloader/Models/Api/ApiEnvelope.cs new file mode 100644 index 0000000..a730140 --- /dev/null +++ b/TelegramDownloader/Models/Api/ApiEnvelope.cs @@ -0,0 +1,129 @@ +namespace TelegramDownloader.Models.Api +{ + /// + /// Envelope returned by every endpoint of the modular v1 API. + /// Clients can always rely on to branch, and on + /// carrying a machine-readable . + /// + /// Type of the payload. + public class ApiResult + { + /// True when the operation completed successfully. + public bool Success { get; set; } + + /// Payload. Null when is false. + public T? Data { get; set; } + + /// Error detail. Null when is true. + public ApiError? Error { get; set; } + + /// Optional human readable note about the operation. + public string? Message { get; set; } + + /// Pagination block, present only on paged list endpoints. + public PageInfo? Page { get; set; } + + public static ApiResult Ok(T data, string? message = null) => + new() { Success = true, Data = data, Message = message }; + + public static ApiResult Ok(T data, PageInfo page) => + new() { Success = true, Data = data, Page = page }; + + public static ApiResult Fail(string code, string message, string? detail = null) => + new() { Success = false, Error = new ApiError { Code = code, Message = message, Detail = detail } }; + } + + /// + /// Non-generic helper used by endpoints that return no payload. + /// + public class ApiResult : ApiResult + { + public static ApiResult Done(string? message = null) => + new() { Success = true, Message = message }; + + public new static ApiResult Fail(string code, string message, string? detail = null) => + new() { Success = false, Error = new ApiError { Code = code, Message = message, Detail = detail } }; + } + + /// + /// Machine readable error description. See . + /// + public class ApiError + { + /// Stable, machine readable code (e.g. channel_not_found). + public string Code { get; set; } = ApiErrorCodes.InternalError; + + /// Short human readable explanation. + public string Message { get; set; } = string.Empty; + + /// Optional extra context (exception message, offending value...). + public string? Detail { get; set; } + } + + /// + /// Canonical set of error codes returned by the v1 API. + /// + public static class ApiErrorCodes + { + public const string Unauthorized = "unauthorized"; + public const string NotLoggedIn = "not_logged_in"; + public const string SetupRequired = "setup_required"; + public const string InvalidRequest = "invalid_request"; + public const string NotFound = "not_found"; + public const string ChannelNotFound = "channel_not_found"; + public const string FileNotFound = "file_not_found"; + public const string TaskNotFound = "task_not_found"; + public const string PlaylistNotFound = "playlist_not_found"; + public const string Conflict = "conflict"; + public const string AlreadyRunning = "already_running"; + public const string Forbidden = "forbidden"; + public const string NotSupported = "not_supported"; + public const string ServiceUnavailable = "service_unavailable"; + public const string InternalError = "internal_error"; + } + + /// + /// Pagination metadata attached to list responses. + /// + public class PageInfo + { + public int Page { get; set; } + public int PageSize { get; set; } + public int TotalItems { get; set; } + public int TotalPages => PageSize > 0 ? (int)Math.Ceiling((double)TotalItems / PageSize) : 0; + public bool HasNext => Page < TotalPages; + public bool HasPrevious => Page > 1; + + public static PageInfo Create(int page, int pageSize, int totalItems) => + new() { Page = page, PageSize = pageSize, TotalItems = totalItems }; + } + + /// + /// Common paging/sorting query parameters. + /// + public class PagedQuery + { + private int _page = 1; + private int _pageSize = 50; + + /// 1-based page number. + public int Page + { + get => _page; + set => _page = value < 1 ? 1 : value; + } + + /// Items per page (1-500). + public int PageSize + { + get => _pageSize; + set => _pageSize = value < 1 ? 1 : (value > 500 ? 500 : value); + } + + /// Field to sort by. Supported values depend on the endpoint. + public string? SortBy { get; set; } + + /// Sort direction. + public bool SortDescending { get; set; } + } +} diff --git a/TelegramDownloader/Models/Api/AuthDtos.cs b/TelegramDownloader/Models/Api/AuthDtos.cs new file mode 100644 index 0000000..911d79c --- /dev/null +++ b/TelegramDownloader/Models/Api/AuthDtos.cs @@ -0,0 +1,90 @@ +namespace TelegramDownloader.Models.Api +{ + /// + /// Step of the Telegram login state machine the server is currently waiting for. + /// + public static class AuthStep + { + /// Server needs a phone number. + public const string Phone = "phone"; + /// Server needs the verification code sent by Telegram. + public const string VerificationCode = "vc"; + /// Server needs the two-factor password. + public const string Password = "pass"; + /// Session is authenticated. + public const string Authenticated = "ok"; + /// The application has not been configured yet (see /api/v1/system/setup). + public const string SetupRequired = "setup_required"; + } + + /// Current authentication state of the Telegram session. + public class AuthStatusDto + { + /// One of the values in . + public string Step { get; set; } = AuthStep.Phone; + + /// True when the session is fully authenticated. + public bool IsAuthenticated { get; set; } + + /// True when API id/hash and MongoDB are configured. + public bool IsConfigured { get; set; } + + /// Signed-in Telegram user, when authenticated. + public TelegramUserDto? User { get; set; } + } + + /// Signed-in Telegram user. + public class TelegramUserDto + { + public long Id { get; set; } + public string? Username { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? Phone { get; set; } + public bool IsPremium { get; set; } + } + + /// Body of POST /api/v1/auth/login. + public class LoginStepRequest + { + /// + /// Value for the current step: the phone number, the verification code + /// or the two-factor password. + /// + public string Value { get; set; } = string.Empty; + + /// + /// Set to true when is a phone number, so the server + /// starts a new login instead of continuing the pending one. + /// + public bool IsPhone { get; set; } + } + + /// QR login session created by POST /api/v1/auth/qr. + public class QrLoginDto + { + /// Identifier used to poll or cancel the QR session. + public string SessionId { get; set; } = string.Empty; + + /// The tg://login?token=... URL to render as a QR code. + public string? LoginUrl { get; set; } + + /// PNG QR image, base64 encoded, ready to be shown as-is. + public string? QrImageBase64 { get; set; } + + /// + /// waiting, password_required, authenticated, + /// cancelled or error. + /// + public string Status { get; set; } = "waiting"; + + /// Error detail when is error. + public string? Error { get; set; } + } + + /// Body of POST /api/v1/auth/qr/{sessionId}/password. + public class QrPasswordRequest + { + public string Password { get; set; } = string.Empty; + } +} diff --git a/TelegramDownloader/Models/Api/ChannelDtos.cs b/TelegramDownloader/Models/Api/ChannelDtos.cs new file mode 100644 index 0000000..5276b77 --- /dev/null +++ b/TelegramDownloader/Models/Api/ChannelDtos.cs @@ -0,0 +1,159 @@ +using TL; + +namespace TelegramDownloader.Models.Api +{ + /// A Telegram chat/channel visible to the signed-in account. + public class ApiChannelDto + { + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + + /// channel, group or chat. + public string Type { get; set; } = "chat"; + + /// True when the signed-in account created the channel. + public bool IsOwner { get; set; } + + /// True when the channel is marked as favourite in the app config. + public bool IsFavorite { get; set; } + + /// True when the channel is hidden from the channel lists in the app config. + public bool IsHidden { get; set; } + + /// Relative URL serving the channel avatar. + public string ImageUrl { get; set; } = string.Empty; + + /// True when the app already has an indexed file database for this channel. + public bool HasDatabase { get; set; } + + public static ApiChannelDto FromChatViewBase(ChatViewBase chat, bool isFavorite = false, bool isOwner = false, bool isHidden = false) + { + var id = chat.chat.ID; + var name = chat.chat switch + { + Channel c => c.title, + Chat ch => ch.title, + _ => chat.chat?.ToString() ?? "Unknown" + }; + var type = chat.chat switch + { + Channel c when c.IsChannel => "channel", + Channel c when c.IsGroup => "group", + Chat => "group", + _ => "chat" + }; + + return new ApiChannelDto + { + Id = id, + Name = name, + Type = type, + IsOwner = isOwner, + IsFavorite = isFavorite, + IsHidden = isHidden, + ImageUrl = $"/api/channel/image/{id}" + }; + } + } + + /// Channel plus indexed-content statistics. + public class ApiChannelDetailDto : ApiChannelDto + { + public int FileCount { get; set; } + public int FolderCount { get; set; } + public long TotalSize { get; set; } + public string TotalSizeText { get; set; } = "0 B"; + public int AudioCount { get; set; } + public int VideoCount { get; set; } + public int PhotoCount { get; set; } + public int DocumentCount { get; set; } + + /// True while a background refresh of this channel is running. + public bool IsRefreshing { get; set; } + + /// True when the account can index/refresh this channel from the UI. + public bool CanRefresh { get; set; } + } + + /// A Telegram chat folder (filter) with the channels it contains. + public class ApiChannelFolderDto + { + public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public string? IconEmoji { get; set; } + public List Channels { get; set; } = new(); + public int ChannelCount => Channels.Count; + } + + /// Channels grouped by Telegram folder. + public class ApiChannelsWithFoldersDto + { + public List Folders { get; set; } = new(); + public List Ungrouped { get; set; } = new(); + public int TotalChannels { get; set; } + } + + /// Body of POST /api/v1/channels. + public class CreateChannelRequest + { + /// Channel title. + public string Title { get; set; } = string.Empty; + + /// Channel description. + public string? About { get; set; } + + /// Create the MongoDB file database for the channel right away. + public bool CreateDatabase { get; set; } = true; + } + + /// Body of POST /api/v1/channels/{id}/refresh. + public class RefreshChannelRequest + { + public bool IncludeDocuments { get; set; } = true; + public bool IncludeAudio { get; set; } = true; + public bool IncludeVideo { get; set; } = true; + public bool IncludePhotos { get; set; } = true; + + /// Re-scan the channel even when a previous scan already completed. + public bool Force { get; set; } + + public RefreshChannelOptions ToOptions() => new() + { + IncludeDocuments = IncludeDocuments, + IncludeAudio = IncludeAudio, + IncludeVideo = IncludeVideo, + IncludePhotos = IncludePhotos + }; + } + + /// A raw Telegram message from a chat history. + public class ApiChatMessageDto + { + public int Id { get; set; } + public DateTime Date { get; set; } + public string? Text { get; set; } + + /// True when the message carries a document/media attachment. + public bool HasMedia { get; set; } + + /// photo, video, audio, document or null. + public string? MediaType { get; set; } + + public string? FileName { get; set; } + public long FileSize { get; set; } + public string? MimeType { get; set; } + + /// Sender display name, when resolvable. + public string? From { get; set; } + } + + /// Body of POST /api/v1/channels/{id}/leave and delete operations. + public class ChannelDeleteRequest + { + /// Also drop the local MongoDB database that indexes the channel. + public bool DeleteLocalDatabase { get; set; } + + /// Delete the channel on Telegram (owner only) instead of just leaving it. + public bool DeleteOnTelegram { get; set; } + } +} diff --git a/TelegramDownloader/Models/Api/FileDtos.cs b/TelegramDownloader/Models/Api/FileDtos.cs new file mode 100644 index 0000000..4a63024 --- /dev/null +++ b/TelegramDownloader/Models/Api/FileDtos.cs @@ -0,0 +1,283 @@ +using TelegramDownloader.Data; +using TelegramDownloader.Services; + +namespace TelegramDownloader.Models.Api +{ + /// + /// A file or folder as stored in the channel index (MongoDB) or on the local disk. + /// + public class ApiFileDto + { + /// MongoDB id for remote entries, relative path for local entries. + public string Id { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + /// Folder path this entry lives in, always ending with /. + public string Path { get; set; } = "/"; + + /// Id of the parent folder (remote entries only). + public string? ParentId { get; set; } + + public bool IsFile { get; set; } + public bool HasChildren { get; set; } + + public long Size { get; set; } + public string SizeText { get; set; } = "0 B"; + + /// File extension including the dot, or folder. + public string Type { get; set; } = string.Empty; + + /// Audio, Video, Photo, Document, Archive, Folder... + public string Category { get; set; } = string.Empty; + + public DateTime DateCreated { get; set; } + public DateTime DateModified { get; set; } + + /// Telegram message id backing this file, when not split. + public int? MessageId { get; set; } + + /// True when the file was uploaded as several Telegram messages. + public bool IsSplit { get; set; } + + public string? Md5Hash { get; set; } + public string? XxHash { get; set; } + + /// Absolute URL for range-capable streaming, when applicable. + public string? StreamUrl { get; set; } + + /// Absolute URL that downloads the whole file. + public string? DownloadUrl { get; set; } + + public static ApiFileDto FromBson(BsonFileManagerModel m, string channelId, string baseUrl) + { + var type = m.Type ?? string.Empty; + var category = m.IsFile ? CategoryOf(type) : "Folder"; + + string? streamUrl = null; + string? downloadUrl = null; + if (m.IsFile) + { + downloadUrl = $"{baseUrl}/api/file/GetFileByTfmId/{Uri.EscapeDataString(m.Name)}?idChannel={channelId}&idFile={m.Id}"; + if (category == "Audio" || category == "Video") + streamUrl = $"{baseUrl}/api/file/GetFileStreamCached/{channelId}/{m.Id}/{Uri.EscapeDataString(m.Name)}"; + } + + return new ApiFileDto + { + Id = m.Id, + Name = m.Name, + Path = string.IsNullOrEmpty(m.FilterPath) ? "/" : m.FilterPath.Replace("\\", "/"), + ParentId = m.ParentId, + IsFile = m.IsFile, + HasChildren = !m.IsFile && m.HasChild, + Size = m.Size, + SizeText = HelperService.SizeSuffix(m.Size), + Type = m.IsFile ? type : "folder", + Category = category, + DateCreated = m.DateCreated, + DateModified = m.DateModified, + MessageId = m.MessageId, + IsSplit = m.isSplit, + Md5Hash = m.MD5Hash, + XxHash = m.XXHash, + StreamUrl = streamUrl, + DownloadUrl = downloadUrl + }; + } + + public static ApiFileDto FromLocalFile(FileInfo file, string relativePath, string baseUrl) + { + var ext = file.Extension.ToLowerInvariant(); + var category = CategoryOf(ext); + string? streamUrl = null; + if (category == "Video") + streamUrl = $"{baseUrl}/api/localvideo/stream?path={Uri.EscapeDataString(relativePath)}"; + else if (category == "Audio") + streamUrl = $"{baseUrl}/local/{EscapePath(relativePath)}"; + + return new ApiFileDto + { + Id = relativePath, + Name = file.Name, + Path = NormalizeFolder(System.IO.Path.GetDirectoryName(relativePath)), + IsFile = true, + HasChildren = false, + Size = file.Length, + SizeText = HelperService.SizeSuffix(file.Length), + Type = ext, + Category = category, + DateCreated = file.CreationTimeUtc, + DateModified = file.LastWriteTimeUtc, + StreamUrl = streamUrl, + DownloadUrl = $"{baseUrl}/local/{EscapePath(relativePath)}" + }; + } + + public static ApiFileDto FromLocalDirectory(DirectoryInfo dir, string relativePath) + { + return new ApiFileDto + { + Id = relativePath, + Name = dir.Name, + Path = NormalizeFolder(System.IO.Path.GetDirectoryName(relativePath)), + IsFile = false, + HasChildren = dir.EnumerateFileSystemInfos().Any(), + Size = 0, + SizeText = "0 B", + Type = "folder", + Category = "Folder", + DateCreated = dir.CreationTimeUtc, + DateModified = dir.LastWriteTimeUtc + }; + } + + private static string EscapePath(string relativePath) => + string.Join('/', relativePath.Replace("\\", "/").Split('/').Select(Uri.EscapeDataString)); + + private static string NormalizeFolder(string? dir) + { + if (string.IsNullOrEmpty(dir)) return "/"; + var p = dir.Replace("\\", "/"); + if (!p.StartsWith('/')) p = "/" + p; + if (!p.EndsWith('/')) p += "/"; + return p; + } + + /// Maps a file extension to the category used across the API. + public static string CategoryOf(string? extension) + { + var ext = extension?.ToLowerInvariant() ?? string.Empty; + if (FileExtensionTypeTest.isAudioExtension(ext)) return "Audio"; + if (FileExtensionTypeTest.isVideoExtension(ext)) return "Video"; + return FileTypeInfo.GetCategory(ext) switch + { + "Images" => "Photo", + "Documents" => "Document", + "Archives" => "Archive", + "Applications" => "Application", + "Video" => "Video", + "Audio" => "Audio", + _ => "Other" + }; + } + } + + /// Listing of a folder plus navigation and aggregate information. + public class ApiFolderContentsDto + { + /// Channel id for remote listings, null for local listings. + public string? ChannelId { get; set; } + + public string CurrentPath { get; set; } = "/"; + public string? CurrentFolderId { get; set; } + public string? ParentPath { get; set; } + public string? ParentFolderId { get; set; } + public string FolderName { get; set; } = string.Empty; + + public List Items { get; set; } = new(); + public ApiFolderStatsDto Stats { get; set; } = new(); + + /// Breadcrumb from the root down to the current folder. + public List Breadcrumbs { get; set; } = new(); + } + + /// One breadcrumb hop. + public class ApiBreadcrumbDto + { + public string Name { get; set; } = string.Empty; + public string Path { get; set; } = "/"; + public string? FolderId { get; set; } + } + + /// Aggregate counters for a folder listing. + public class ApiFolderStatsDto + { + public int FolderCount { get; set; } + public int FileCount { get; set; } + public int AudioCount { get; set; } + public int VideoCount { get; set; } + public int PhotoCount { get; set; } + public int DocumentCount { get; set; } + public long TotalSize { get; set; } + public string TotalSizeText { get; set; } = "0 B"; + } + + /// Query string for browse/search endpoints. + public class BrowseQuery : PagedQuery + { + /// Folder id to list (remote listings). Empty means the channel root. + public string? FolderId { get; set; } + + /// Folder path to list. Used when is not supplied. + public string? Path { get; set; } + + /// Restrict to a category: audio, video, photo, document, archive, all. + public string? Filter { get; set; } + + /// Case-insensitive substring match on the file name. + public string? Search { get; set; } + + /// Hide folders and return only files. + public bool FilesOnly { get; set; } + } + + /// Body of POST /api/v1/channels/{channelId}/files/folders. + public class CreateFolderRequest + { + /// Parent folder path, e.g. /music/. Defaults to the root. + public string Path { get; set; } = "/"; + + /// Name of the new folder. + public string Name { get; set; } = string.Empty; + } + + /// Body of PUT /api/v1/channels/{channelId}/files/{fileId}/name. + public class RenameRequest + { + public string NewName { get; set; } = string.Empty; + } + + /// Body of the delete/copy/move endpoints. + public class FileIdsRequest + { + /// Ids of the entries to operate on. + public List Ids { get; set; } = new(); + } + + /// Body of POST /api/v1/channels/{channelId}/files/copy and /move. + public class CopyMoveRequest : FileIdsRequest + { + /// Destination folder path, e.g. /backup/. + public string TargetPath { get; set; } = "/"; + + /// Destination folder id. Takes precedence over . + public string? TargetFolderId { get; set; } + } + + /// Body of the local file-system mutation endpoints. + public class LocalPathRequest + { + /// Path relative to the local root, e.g. music/rock. + public string Path { get; set; } = string.Empty; + } + + /// Body of POST /api/v1/local/folders. + public class LocalCreateFolderRequest : LocalPathRequest + { + public string Name { get; set; } = string.Empty; + } + + /// Body of POST /api/v1/local/rename. + public class LocalRenameRequest : LocalPathRequest + { + public string NewName { get; set; } = string.Empty; + } + + /// Body of POST /api/v1/local/delete. + public class LocalDeleteRequest + { + /// Paths relative to the local root. + public List Paths { get; set; } = new(); + } +} diff --git a/TelegramDownloader/Models/Api/SystemDtos.cs b/TelegramDownloader/Models/Api/SystemDtos.cs new file mode 100644 index 0000000..8dd5c07 --- /dev/null +++ b/TelegramDownloader/Models/Api/SystemDtos.cs @@ -0,0 +1,271 @@ +using TelegramDownloader.Services; + +namespace TelegramDownloader.Models.Api +{ + /// Server identity and health, returned by GET /api/v1/system/info. + public class ServerInfoDto + { + public string Product { get; set; } = "TelegramFileManager"; + public string Version { get; set; } = string.Empty; + + /// Highest API version this server implements. + public string ApiVersion { get; set; } = "1.0"; + + public DateTime ServerTimeUtc { get; set; } = DateTime.UtcNow; + public bool MongoConnected { get; set; } + public bool TelegramConfigured { get; set; } + public bool TelegramAuthenticated { get; set; } + public bool SetupComplete { get; set; } + + /// Relative path of the SignalR hub streaming transfer updates. + public string TransfersHubPath { get; set; } = "/hubs/transfers"; + + /// True when the server requires an X-Api-Key header. + public bool RequiresApiKey { get; set; } + } + + /// Machine resource usage, returned by GET /api/v1/system/metrics. + public class SystemMetricsDto + { + public double SystemCpuUsage { get; set; } + public double AppCpuUsage { get; set; } + public int ProcessorCount { get; set; } + + public long TotalMemoryBytes { get; set; } + public long UsedMemoryBytes { get; set; } + public long AvailableMemoryBytes { get; set; } + public double MemoryUsagePercent { get; set; } + public long AppMemoryBytes { get; set; } + + public string? TempFolderPath { get; set; } + public long TempFolderSizeBytes { get; set; } + public long DiskTotalBytes { get; set; } + public long DiskUsedBytes { get; set; } + public long DiskFreeBytes { get; set; } + public double DiskUsagePercent { get; set; } + + public static SystemMetricsDto From(SystemMetrics m) => new() + { + SystemCpuUsage = m.SystemCpuUsage, + AppCpuUsage = m.AppCpuUsage, + ProcessorCount = m.ProcessorCount, + TotalMemoryBytes = m.TotalMemoryBytes, + UsedMemoryBytes = m.UsedMemoryBytes, + AvailableMemoryBytes = m.AvailableMemoryBytes, + MemoryUsagePercent = m.MemoryUsagePercent, + AppMemoryBytes = m.AppMemoryBytes, + TempFolderPath = m.TempFolderPath, + TempFolderSizeBytes = m.TempFolderSizeBytes, + DiskTotalBytes = m.DiskTotalBytes, + DiskUsedBytes = m.DiskUsedBytes, + DiskFreeBytes = m.DiskFreeBytes, + DiskUsagePercent = m.DiskUsagePercent + }; + } + + /// Progress of the first-run wizard. + public class SetupStatusDto + { + /// Complete, MongoDbRequired or TelegramRequired. + public string CurrentStep { get; set; } = string.Empty; + public bool MongoDbConfigured { get; set; } + public bool MongoDbConnected { get; set; } + public bool TelegramConfigured { get; set; } + public string? MongoDbError { get; set; } + } + + /// Statistics of one indexed channel database. + public class DatabaseStatsDto + { + public string ChannelId { get; set; } = string.Empty; + public string? ChannelName { get; set; } + public long SizeInBytes { get; set; } + public string SizeText { get; set; } = "0 B"; + public long DocumentCount { get; set; } + public DateTime? CreatedAt { get; set; } + public DateTime? LastModified { get; set; } + } + + /// Result of a filter-path integrity analysis on a channel database. + public class PathAnalysisDto + { + public string DatabaseName { get; set; } = string.Empty; + public int TotalItems { get; set; } + public int ItemsWithIssues { get; set; } + public int FilterPathIssues { get; set; } + public int FilterIdIssues { get; set; } + public int FilePathIssues { get; set; } + public bool HasIssues { get; set; } + public string? Error { get; set; } + } + + /// Application configuration exposed for reading and updating. + public class AppConfigDto + { + public bool ShouldNotify { get; set; } + public int TimeSleepBetweenTransactions { get; set; } + public int SplitSize { get; set; } + public int MaxSimultaneousDownloads { get; set; } + public bool CheckHash { get; set; } + public int MaxImageUploadSizeInMb { get; set; } + public int MaxPreloadFileSizeInMb { get; set; } + public bool ShouldShowCaptionPath { get; set; } + public bool ShouldShowLogInTerminal { get; set; } + + /// DirectStream, ProgressiveCache or Preload. + public string StrmStreamingMode { get; set; } = nameof(StreamingMode.DirectStream); + + public bool ShouldShowPaginatedFileChannel { get; set; } + public bool ShowChannelImages { get; set; } + public bool ShowHiddenChannels { get; set; } + public List FavouriteChannels { get; set; } = new(); + public List HiddenChannels { get; set; } = new(); + + public bool EnableTaskPersistence { get; set; } + public int TaskPersistenceDebounceSeconds { get; set; } + public int StaleTaskCleanupDays { get; set; } + public bool AutoResumeOnStartup { get; set; } + + public bool EnableVideoTranscoding { get; set; } + public bool EnableRefreshOwnChannels { get; set; } + + public bool EnableMemorySplitUpload { get; set; } + public int MemorySplitSizeGB { get; set; } + public int ParallelTransfers { get; set; } + + public bool EnableMultiConnectionDownloads { get; set; } + public int DownloadConnections { get; set; } + public int MultiConnectionPartSizeKB { get; set; } + public int MultiConnectionBlockSizeMB { get; set; } + public int MultiConnectionMinFileSizeMB { get; set; } + + public static AppConfigDto From(GeneralConfig c) => new() + { + ShouldNotify = c.ShouldNotify, + TimeSleepBetweenTransactions = c.TimeSleepBetweenTransactions, + SplitSize = c.SplitSize, + MaxSimultaneousDownloads = c.MaxSimultaneousDownloads, + CheckHash = c.CheckHash, + MaxImageUploadSizeInMb = c.MaxImageUploadSizeInMb, + MaxPreloadFileSizeInMb = c.MaxPreloadFileSizeInMb, + ShouldShowCaptionPath = c.ShouldShowCaptionPath, + ShouldShowLogInTerminal = c.ShouldShowLogInTerminal, + StrmStreamingMode = c.GetEffectiveStreamingMode().ToString(), + ShouldShowPaginatedFileChannel = c.ShouldShowPaginatedFileChannel, + ShowChannelImages = c.ShowChannelImages, + ShowHiddenChannels = c.ShowHiddenChannels, + FavouriteChannels = c.FavouriteChannels ?? new List(), + HiddenChannels = c.HiddenChannels ?? new List(), + EnableTaskPersistence = c.EnableTaskPersistence, + TaskPersistenceDebounceSeconds = c.TaskPersistenceDebounceSeconds, + StaleTaskCleanupDays = c.StaleTaskCleanupDays, + AutoResumeOnStartup = c.AutoResumeOnStartup, + EnableVideoTranscoding = c.EnableVideoTranscoding, + EnableRefreshOwnChannels = c.EnableRefreshOwnChannels, + EnableMemorySplitUpload = c.EnableMemorySplitUpload, + MemorySplitSizeGB = c.MemorySplitSizeGB, + ParallelTransfers = c.ParallelTransfers, + EnableMultiConnectionDownloads = c.EnableMultiConnectionDownloads, + DownloadConnections = c.DownloadConnections, + MultiConnectionPartSizeKB = c.MultiConnectionPartSizeKB, + MultiConnectionBlockSizeMB = c.MultiConnectionBlockSizeMB, + MultiConnectionMinFileSizeMB = c.MultiConnectionMinFileSizeMB + }; + } + + /// + /// Partial configuration update. Only the properties present in the request + /// body are applied; everything else keeps its current value. + /// + public class UpdateConfigRequest + { + public bool? ShouldNotify { get; set; } + public int? TimeSleepBetweenTransactions { get; set; } + public int? SplitSize { get; set; } + public int? MaxSimultaneousDownloads { get; set; } + public bool? CheckHash { get; set; } + public int? MaxImageUploadSizeInMb { get; set; } + public int? MaxPreloadFileSizeInMb { get; set; } + public bool? ShouldShowCaptionPath { get; set; } + public bool? ShouldShowLogInTerminal { get; set; } + public string? StrmStreamingMode { get; set; } + public bool? ShouldShowPaginatedFileChannel { get; set; } + public bool? ShowChannelImages { get; set; } + public bool? ShowHiddenChannels { get; set; } + public bool? EnableTaskPersistence { get; set; } + public int? TaskPersistenceDebounceSeconds { get; set; } + public int? StaleTaskCleanupDays { get; set; } + public bool? AutoResumeOnStartup { get; set; } + public bool? EnableVideoTranscoding { get; set; } + public bool? EnableRefreshOwnChannels { get; set; } + public bool? EnableMemorySplitUpload { get; set; } + public int? MemorySplitSizeGB { get; set; } + public int? ParallelTransfers { get; set; } + public bool? EnableMultiConnectionDownloads { get; set; } + public int? DownloadConnections { get; set; } + public int? MultiConnectionPartSizeKB { get; set; } + public int? MultiConnectionBlockSizeMB { get; set; } + public int? MultiConnectionMinFileSizeMB { get; set; } + } + + /// One application log record. + public class LogEntryDto + { + public string Id { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } + public string? Level { get; set; } + public string? Message { get; set; } + public string? Logger { get; set; } + public string? Exception { get; set; } + public string? Version { get; set; } + } + + /// Query string for GET /api/v1/system/logs. + public class LogQuery : PagedQuery + { + public DateTime? FromDate { get; set; } + public DateTime? ToDate { get; set; } + + /// Verbose, Debug, Information, Warning, Error, Fatal. + public string? Level { get; set; } + + public string? Logger { get; set; } + public string? Version { get; set; } + public string? Search { get; set; } + } + + /// A shared file collection published by another user. + public class SharedCollectionDto + { + public string Id { get; set; } = string.Empty; + public string? Name { get; set; } + public string? Description { get; set; } + public string? ChannelId { get; set; } + public string? CollectionId { get; set; } + public DateTime DateCreated { get; set; } + public DateTime DateModified { get; set; } + } + + /// Body of POST /api/v1/shares/import. + public class ImportSharedRequest + { + /// Share payload, normally obtained from GET /api/file/share/{id}. + public ShareFilesModel Share { get; set; } = new(); + } + + /// Body of POST /api/v1/channels/{id}/strm. + public class CreateStrmRequest + { + /// Channel folder to export, e.g. /movies/. + public string Path { get; set; } = "/"; + + /// Base URL written inside the .strm files. Defaults to the request host. + public string? Host { get; set; } + + /// + /// When set, .strm files are written to this folder under the server local + /// root instead of being returned as a zip download link. + /// + public string? DestinationFolder { get; set; } + } +} diff --git a/TelegramDownloader/Models/Api/TransferDtos.cs b/TelegramDownloader/Models/Api/TransferDtos.cs new file mode 100644 index 0000000..042fff4 --- /dev/null +++ b/TelegramDownloader/Models/Api/TransferDtos.cs @@ -0,0 +1,304 @@ +using TelegramDownloader.Models.Persistence; +using TelegramDownloader.Services; + +namespace TelegramDownloader.Models.Api +{ + /// Kind of transfer reported by the API and the SignalR hub. + public static class TransferKind + { + public const string Download = "download"; + public const string Upload = "upload"; + /// A batch job that spawns individual downloads/uploads. + public const string Task = "task"; + } + + /// + /// A single running/queued/finished transfer. Shape is shared by the REST + /// endpoints and by the transfers SignalR hub, so a client can render + /// the same view from a snapshot or from a live event. + /// + public class TransferDto + { + /// Stable id of the transfer. Use it to pause/resume/cancel. + public string Id { get; set; } = string.Empty; + + /// One of . + public string Kind { get; set; } = TransferKind.Download; + + /// Operation label: Download, Upload, Splitting, MD5 Calc, XxHash Calc. + public string Action { get; set; } = string.Empty; + + /// Error, Pending, Canceled, Paused, Completed or Working. + public string State { get; set; } = nameof(StateTask.Pending); + + /// True when the transfer sits in the queue instead of running. + public bool IsQueued { get; set; } + + public string Name { get; set; } = string.Empty; + + /// Destination path for downloads, source path for uploads. + public string? Path { get; set; } + + public string? ChannelId { get; set; } + public string? ChannelName { get; set; } + + public long Size { get; set; } + public long Transmitted { get; set; } + public string SizeText { get; set; } = "0 B"; + public string TransmittedText { get; set; } = "0 B"; + + /// Completion percentage, 0-100. + public int Progress { get; set; } + + public DateTime CreatedAt { get; set; } + public DateTime? StartedAt { get; set; } + public DateTime? EndedAt { get; set; } + + // Batch-only fields + /// Number of files in the batch (batch tasks only). + public int? TotalItems { get; set; } + /// Number of files already processed (batch tasks only). + public int? ExecutedItems { get; set; } + /// True when a batch task uploads, false when it downloads. + public bool? IsUpload { get; set; } + public string? FromPath { get; set; } + public string? ToPath { get; set; } + + public static TransferDto FromDownload(DownloadModel m, bool isQueued = false) => new() + { + Id = m._internalId, + Kind = TransferKind.Download, + Action = m.action, + State = m.state.ToString(), + IsQueued = isQueued, + Name = m.name ?? string.Empty, + Path = m.path, + ChannelId = m.PersistenceChannelId, + ChannelName = m.channelName, + Size = m._size, + Transmitted = m._transmitted, + SizeText = m._sizeString ?? HelperService.SizeSuffix(m._size), + TransmittedText = m._transmittedString ?? HelperService.SizeSuffix(m._transmitted), + Progress = m.progress, + CreatedAt = m.creationDate, + StartedAt = m.startDate == default ? null : m.startDate, + EndedAt = m.endnDate == default ? null : m.endnDate + }; + + public static TransferDto FromUpload(UploadModel m, bool isQueued = false) => new() + { + Id = m._internalId, + Kind = TransferKind.Upload, + Action = m.action, + State = m.state.ToString(), + IsQueued = isQueued, + Name = m.name ?? string.Empty, + Path = m.path, + ChannelId = m.PersistenceChannelId, + ChannelName = m.chatName, + Size = m._size, + Transmitted = m._transmitted, + SizeText = m._sizeString ?? HelperService.SizeSuffix(m._size), + TransmittedText = m._transmittedString ?? HelperService.SizeSuffix(m._transmitted), + Progress = m.progress, + CreatedAt = m.creationDate, + StartedAt = m.startDate == default ? null : m.startDate, + EndedAt = m.endnDate == default ? null : m.endnDate + }; + + public static TransferDto FromBatch(InfoDownloadTaksModel m) => new() + { + Id = m._internalId, + Kind = TransferKind.Task, + Action = m.isUpload ? "Upload batch" : "Download batch", + State = m.state.ToString(), + IsQueued = m.state == StateTask.Pending, + Name = m.isUpload ? (m.toPath ?? "batch") : (m.fromPath ?? "batch"), + ChannelId = m.channelId, + Size = m.totalSize, + Transmitted = m.executedSize, + SizeText = HelperService.SizeSuffix(m.totalSize), + TransmittedText = HelperService.SizeSuffix(m.executedSize), + Progress = m.progress, + CreatedAt = m.creationDate, + EndedAt = m.endnDate == default ? null : m.endnDate, + TotalItems = m.total, + ExecutedItems = m.executed, + IsUpload = m.isUpload, + FromPath = m.fromPath, + ToPath = m.toPath + }; + } + + /// + /// Aggregate view of everything in flight. This is the payload of the + /// TransfersSnapshot hub message and of GET /api/v1/transfers. + /// + public class TransfersSnapshotDto + { + public List Downloads { get; set; } = new(); + public List QueuedDownloads { get; set; } = new(); + public List Uploads { get; set; } = new(); + public List QueuedUploads { get; set; } = new(); + public List Tasks { get; set; } = new(); + public TransferSummaryDto Summary { get; set; } = new(); + } + + /// + /// Lightweight counters and current speeds. Pushed on its own as + /// TransferSummary so clients can render a status bar cheaply. + /// + public class TransferSummaryDto + { + public int ActiveDownloads { get; set; } + public int QueuedDownloads { get; set; } + public int ActiveUploads { get; set; } + public int QueuedUploads { get; set; } + public int ActiveTasks { get; set; } + public int TotalTasks { get; set; } + + /// Human readable download speed, e.g. 4.2 MB/s. + public string DownloadSpeed { get; set; } = "0 KB/s"; + + /// Human readable upload speed. + public string UploadSpeed { get; set; } = "0 KB/s"; + + /// Bytes transferred during the current sampling second. + public long DownloadBytesPerSecond { get; set; } + public long UploadBytesPerSecond { get; set; } + + /// True when the download queue has been paused globally. + public bool DownloadsPaused { get; set; } + + public bool IsWorking => ActiveDownloads > 0 || ActiveUploads > 0 || ActiveTasks > 0; + } + + /// One sample of the speed history chart. + public class SpeedPointDto + { + public DateTime Time { get; set; } + public long BytesPerSecond { get; set; } + public string SpeedText { get; set; } = "0 KB/s"; + public List ActiveFiles { get; set; } = new(); + + public static SpeedPointDto From(SpeedHistory h) => new() + { + Time = h.time, + BytesPerSecond = h.speed, + SpeedText = h.speedString ?? "0 KB/s", + ActiveFiles = h.activeFiles ?? new List() + }; + } + + /// Download and upload speed history, used to draw charts. + public class SpeedHistoryDto + { + public List Download { get; set; } = new(); + public List Upload { get; set; } = new(); + + /// Seconds between samples. + public int IntervalSeconds { get; set; } = TransactionInfoService.INTERVAL_SPEED_HISTORY_SECONDS; + + /// How long samples are retained, in seconds. + public int WindowSeconds { get; set; } = TransactionInfoService.MAX_SPEED_HISTORY_SECONDS; + } + + /// Body of POST /api/v1/transfers/downloads. + public class StartDownloadRequest + { + /// Channel whose indexed files should be downloaded. + public string ChannelId { get; set; } = string.Empty; + + /// Ids of the files/folders to download. Folders are pulled recursively. + public List FileIds { get; set; } = new(); + + /// + /// Destination folder relative to the server local root. Null keeps the + /// original channel folder structure. + /// + public string? TargetPath { get; set; } + + /// Set when downloading from a shared collection instead of an owned channel. + public string? SharedCollectionId { get; set; } + } + + /// Body of POST /api/v1/transfers/uploads. + public class StartUploadRequest + { + /// Destination channel. + public string ChannelId { get; set; } = string.Empty; + + /// Paths relative to the server local root. Folders are pushed recursively. + public List LocalPaths { get; set; } = new(); + + /// Destination folder inside the channel, e.g. /backup/. Defaults to the root. + public string? TargetPath { get; set; } + } + + /// Body of POST /api/v1/transfers/messages. + public class DownloadMessagesRequest + { + /// Chat the messages belong to. + public long ChatId { get; set; } + + /// Telegram message ids carrying the media to download. + public List MessageIds { get; set; } = new(); + + /// Destination folder relative to the server local root. + public string? TargetPath { get; set; } + } + + /// Result returned when a transfer batch has been queued. + public class TransferAcceptedDto + { + /// Number of items accepted for transfer. + public int Accepted { get; set; } + + /// Ids that could not be resolved and were skipped. + public List Skipped { get; set; } = new(); + + /// Id of the batch task, when the operation created one. + public string? TaskId { get; set; } + } + + /// A transfer restored from MongoDB after an application restart. + public class PersistedTaskDto + { + public string Id { get; set; } = string.Empty; + public string InternalId { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string State { get; set; } = string.Empty; + public string? Name { get; set; } + public string? ChannelId { get; set; } + public string? ChannelName { get; set; } + public long TotalSize { get; set; } + public long TransmittedBytes { get; set; } + public int Progress { get; set; } + public string? SourcePath { get; set; } + public string? DestinationPath { get; set; } + public DateTime CreationDate { get; set; } + public DateTime LastUpdated { get; set; } + public int RetryCount { get; set; } + public string? LastError { get; set; } + + public static PersistedTaskDto From(PersistedTaskModel m) => new() + { + Id = m.Id, + InternalId = m.InternalId, + Type = m.Type.ToString(), + State = m.State.ToString(), + Name = m.Name, + ChannelId = m.ChannelId, + ChannelName = m.ChannelName, + TotalSize = m.TotalSize, + TransmittedBytes = m.TransmittedBytes, + Progress = m.Progress, + SourcePath = m.SourcePath, + DestinationPath = m.DestinationPath, + CreationDate = m.CreationDate, + LastUpdated = m.LastUpdated, + RetryCount = m.RetryCount, + LastError = m.LastError + }; + } +} diff --git a/TelegramDownloader/Models/FileModel.cs b/TelegramDownloader/Models/FileModel.cs index e16cbd0..b14ed65 100644 --- a/TelegramDownloader/Models/FileModel.cs +++ b/TelegramDownloader/Models/FileModel.cs @@ -179,42 +179,6 @@ public FileDetails toFileDetails() }; } - public WebDavFileModel toWebDavFileModel(String? channel = null) - { - return this.IsFile - ? new WebDavFileModel() - { - name = this.Name, - is_dir = false, - file_id = this.Id, - content_type = FileService.getMimeType(this.Type), - content_length = this.Size, - channel = channel, - last_modified = this.DateModified - } - : new WebDavFileModel() - { - name = this.Name, - is_dir = true, - last_modified = this.DateModified - }; - } - } - - public class WebDavFileModel - { - public string name { get; set; } - public bool is_dir { get; set; } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public string file_id { get; set; } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public string content_type { get; set; } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public long content_length { get; set; } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public string? channel { get; set; } - public DateTime last_modified { get; set; } - } public class FileManagerModel diff --git a/TelegramDownloader/Models/GeneralConfig.cs b/TelegramDownloader/Models/GeneralConfig.cs index 9cf4911..3e07635 100644 --- a/TelegramDownloader/Models/GeneralConfig.cs +++ b/TelegramDownloader/Models/GeneralConfig.cs @@ -76,6 +76,16 @@ public static void DeleteFavouriteChannel(long id) config.FavouriteChannels.Remove(id); } + public static void AddHiddenChannel(long id) + { + config.HiddenChannels.Add(id); + } + + public static void DeleteHiddenChannel(long id) + { + config.HiddenChannels.Remove(id); + } + public static void loadDbConfig() { tlconfig = LoadJson("./Configuration/config.json"); @@ -139,8 +149,20 @@ public StreamingMode GetEffectiveStreamingMode() public bool hasFileManagerVirtualScroll { get; set; } = false; public bool UseMobileFileManagerAlways { get; set; } = false; public bool ShowChannelImages { get; set; } = false; + /// When true, channels marked as hidden are still shown in the + /// channel lists (web + API); when false they are excluded. + public bool ShowHiddenChannels { get; set; } = false; public List FavouriteChannels { get; set; } = new List(); - public WebDavModel webDav { get; set; } = new WebDavModel(); + /// Ids of channels the user chose to hide from the channel lists. + public List HiddenChannels { get; set; } = new List(); + + // API / WebDAV credentials, managed from the Config page and persisted in + // MongoDB. When set here they take precedence over the equivalents in + // config.json (TLConfig), so they can be changed from the UI without + // editing files or restarting. Empty => fall back to config.json. + public string? MobileApiKey { get; set; } + public string? WebDavUser { get; set; } + public string? WebDavPassword { get; set; } // Task Persistence Settings public bool EnableTaskPersistence { get; set; } = true; @@ -247,24 +269,19 @@ public class TLConfig /// API key for mobile app authentication. If set, mobile API endpoints require this key in X-Api-Key header. /// public string? mobile_api_key { get; set; } - } - - public class WebDavModel - { - public string Host { get; set; } = "127.0.0.1"; - public int PuertoEntrada { get; set; } = 8080; - public int PuertoSalida { get; set; } = 9081; - [BsonIgnore] - public WebbDavService? webDavService { get; set; } = new WebbDavService(); - public void start() - { - webDavService.Start(port: PuertoEntrada, externalPort: PuertoSalida, host: Host); - } + /// + /// Username for the native WebDAV endpoint (HTTP Basic). If empty, the + /// WebDAV endpoint is open (development mode). Used by Hyper Backup and + /// other WebDAV clients that authenticate with user/password. + /// + public string? webdav_user { get; set; } - public void stop() - { - webDavService.Stop(); - } + /// + /// Password for the native WebDAV endpoint (HTTP Basic). Only checked + /// when is set. Serve over HTTPS: Basic auth + /// sends credentials base64-encoded. + /// + public string? webdav_password { get; set; } } } diff --git a/TelegramDownloader/Models/Mobile/ChannelDTOs.cs b/TelegramDownloader/Models/Mobile/ChannelDTOs.cs index 4a9115f..7e64b83 100644 --- a/TelegramDownloader/Models/Mobile/ChannelDTOs.cs +++ b/TelegramDownloader/Models/Mobile/ChannelDTOs.cs @@ -16,10 +16,11 @@ public class ChannelDto public bool IsOwner { get; set; } public bool CanPost { get; set; } public bool IsFavorite { get; set; } + public bool IsHidden { get; set; } public string Type { get; set; } = string.Empty; // channel, group, chat public int FileCount { get; set; } - public static ChannelDto FromChatViewBase(ChatViewBase chat, bool isFavorite = false, bool isOwner = false) + public static ChannelDto FromChatViewBase(ChatViewBase chat, bool isFavorite = false, bool isOwner = false, bool isHidden = false) { var channelId = chat.chat.ID; var name = chat.chat switch @@ -51,6 +52,7 @@ public static ChannelDto FromChatViewBase(ChatViewBase chat, bool isFavorite = f IsOwner = isOwner, CanPost = canPost, IsFavorite = isFavorite, + IsHidden = isHidden, Type = type }; } diff --git a/TelegramDownloader/Pages/Config.razor b/TelegramDownloader/Pages/Config.razor index 3be4e2e..d11aec3 100644 --- a/TelegramDownloader/Pages/Config.razor +++ b/TelegramDownloader/Pages/Config.razor @@ -724,6 +724,21 @@ +
+
+
+ + Show Hidden Channels +
+
+ Show channels you've marked as hidden in the sidebar and channel lists (needed to unhide them) +
+
+
+ +
+
+
@@ -1023,6 +1038,58 @@
+ +
+
+ + API & WebDAV Access +
+
+
+
+
+ + Mobile / v1 API Key +
+
+ Required in the X-Api-Key header for /api/mobile, /api/v1 and /hubs. Leave empty to disable authentication. Overrides mobile_api_key in config.json when set. +
+
+
+ +
+
+
+
+
+ + WebDAV User +
+
+ HTTP Basic user for the native WebDAV endpoint (/webdav). Leave empty to leave WebDAV open. Overrides config.json when set. +
+
+
+ +
+
+
+
+
+ + WebDAV Password +
+
+ HTTP Basic password for the native WebDAV endpoint. Serve WebDAV over HTTPS: Basic auth is base64-encoded, not encrypted. +
+
+
+ +
+
+
+
+
- } + @@ -73,9 +70,8 @@ - - - + + + /// Builds the WebDAV URL for the current channel and folder and shows it in a + /// copyable modal (e.g. to paste into a Synology Hyper Backup task). + /// public async Task openWebDavInfo() { - webDavUrl = $"{new Uri(MyNavigationManager.BaseUri).Host}:{GeneralConfigStatic.config.webDav.PuertoSalida}/{id}/"; - mediaUrlModal.OnShowModalClick(); + var folder = fileManagerImpl?.GetCurrentFolderPath() ?? "/"; + var suffix = string.Join("/", folder + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Select(Uri.EscapeDataString)); + if (suffix.Length > 0) suffix += "/"; + webDavUrl = $"{MyNavigationManager.BaseUri}webdav/{id}/{suffix}"; + StateHasChanged(); + await webDavModal.OnShowModalClick(); } public void exportData() diff --git a/TelegramDownloader/Pages/Partials/impl/FileManagerImpl.razor b/TelegramDownloader/Pages/Partials/impl/FileManagerImpl.razor index 681fd55..0d89163 100644 --- a/TelegramDownloader/Pages/Partials/impl/FileManagerImpl.razor +++ b/TelegramDownloader/Pages/Partials/impl/FileManagerImpl.razor @@ -149,6 +149,17 @@ MobileFileManager mobileFileManager { get; set; } NotificationModel nm = new NotificationModel(); + /// + /// Current folder path inside the channel (e.g. "/" or "/Movies/2024/"), taken + /// from whichever file manager (desktop or mobile) is active. Used to build the + /// WebDAV URL for the current folder. + /// + public string GetCurrentFolderPath() + { + var path = _isMobileView ? mobileFileManager?.Path : fm?.Path; + return string.IsNullOrEmpty(path) ? "/" : path; + } + // Mobile detection private bool _isMobileView = false; private bool _viewportDetected = false; // Don't render until viewport is detected diff --git a/TelegramDownloader/Pages/WebDavInfo.razor b/TelegramDownloader/Pages/WebDavInfo.razor deleted file mode 100644 index f61ea58..0000000 --- a/TelegramDownloader/Pages/WebDavInfo.razor +++ /dev/null @@ -1,159 +0,0 @@ -@page "/webdavinfo" -@using Microsoft.AspNetCore.Components.Forms -@using TelegramDownloader.Data.db -@using TelegramDownloader.Models - -@inject IDbService db -@inject ToastService toastService - -WebDAV Configuration - -@if(model != null) { -
- -
-

WebDAV Server

-

Configure your WebDAV server to access Telegram files from any file manager

-
- - -
- -
-
- -

Configuration

-
-
- -
- - -
Use 0.0.0.0 to allow external connections
-
- -
- - -
Telegram File Manager internal port
-
- -
- - -
Port exposed for WebDAV connections
-
- - -
- -
-
- - How to connect -
-

- Use any WebDAV client (Windows Explorer, macOS Finder, Cyberduck, etc.) - and connect to the WebDAV URL shown in the status panel. -

-
-
-
- - -
-
- -

Server Status

-
-
- -
-
- -
-
- - -
-

@(IsRunning ? "Running" : "Stopped")

-

@(IsRunning ? "WebDAV server is accepting connections" : "Server is not running")

-
- - -
- - - -
- - - @if(IsRunning) - { -
-
Connection URL
-
- http://@(model.webDav.Host):@(model.webDav.PuertoSalida)/ -
-
- } -
-
-
-
-} - - -@code { - private GeneralConfig model { get; set; } - private bool IsRunning => model?.webDav?.webDavService?.IsRunning ?? false; - - protected override async Task OnInitializedAsync() { - model = null; - await loadModel(); - } - - private async Task loadModel() - { - model ??= await GeneralConfigStatic.Load(db); - await InvokeAsync(StateHasChanged); - } - - private async Task OnSave() - { - await GeneralConfigStatic.SaveChanges(db, model); - toastService.Notify(new(ToastType.Success, $"WebDAV configuration has been saved") { Title = "Success", AutoHide = true }); - } - - private async Task Start() - { - model.webDav.start(); - await InvokeAsync(StateHasChanged); - } - - private async Task Stop() - { - model.webDav.stop(); - await InvokeAsync(StateHasChanged); - } - - private async Task Reset() - { - model.webDav.stop(); - await Task.Delay(500); - model.webDav.start(); - await InvokeAsync(StateHasChanged); - } -} diff --git a/TelegramDownloader/Pages/WebDavInfo.razor.css b/TelegramDownloader/Pages/WebDavInfo.razor.css deleted file mode 100644 index a17cfca..0000000 --- a/TelegramDownloader/Pages/WebDavInfo.razor.css +++ /dev/null @@ -1,369 +0,0 @@ -/* ===== WebDAV Configuration Page Styles ===== */ - -.webdav-page { - max-width: 800px; - margin: 0 auto; - padding: 2rem 1rem; -} - -/* Page Header */ -.webdav-header { - text-align: center; - margin-bottom: 2rem; -} - -.webdav-header h1 { - color: #fff; - font-size: 1.75rem; - font-weight: 600; - margin-bottom: 0.5rem; - display: flex; - align-items: center; - justify-content: center; - gap: 0.75rem; -} - -.webdav-header h1 i { - color: #0088cc; -} - -.webdav-header p { - color: rgba(255, 255, 255, 0.6); - font-size: 0.9rem; -} - -/* Cards Grid */ -.webdav-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1.5rem; -} - -@media (max-width: 768px) { - .webdav-grid { - grid-template-columns: 1fr; - } -} - -/* Card Base */ -.webdav-card { - background: rgba(255, 255, 255, 0.05); - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 1rem; - overflow: hidden; -} - -.webdav-card-header { - background: linear-gradient(135deg, rgba(0, 136, 204, 0.2) 0%, rgba(0, 136, 204, 0.05) 100%); - padding: 1rem 1.25rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - display: flex; - align-items: center; - gap: 0.75rem; -} - -.webdav-card-header i { - font-size: 1.25rem; - color: #0088cc; -} - -.webdav-card-header h3 { - margin: 0; - font-size: 1rem; - font-weight: 600; - color: #fff; -} - -.webdav-card-body { - padding: 1.5rem; -} - -/* Form Styles */ -.form-group { - margin-bottom: 1.25rem; -} - -.form-group:last-of-type { - margin-bottom: 1.5rem; -} - -.form-group label { - display: block; - color: rgba(255, 255, 255, 0.8); - font-size: 0.875rem; - font-weight: 500; - margin-bottom: 0.5rem; -} - -.form-group label i { - margin-right: 0.5rem; - color: rgba(255, 255, 255, 0.5); -} - -.form-group ::deep input, -.form-group ::deep .form-control { - width: 100%; - background: rgba(255, 255, 255, 0.05) !important; - border: 1px solid rgba(255, 255, 255, 0.15) !important; - border-radius: 0.5rem; - padding: 0.75rem 1rem; - color: #fff !important; - font-size: 0.9rem; - transition: all 0.2s ease; -} - -.form-group ::deep input:focus, -.form-group ::deep .form-control:focus { - outline: none; - border-color: #0088cc !important; - background: rgba(255, 255, 255, 0.08) !important; - box-shadow: 0 0 0 3px rgba(0, 136, 204, 0.2); -} - -.form-group ::deep input::placeholder { - color: rgba(255, 255, 255, 0.4); -} - -/* Input hint */ -.input-hint { - font-size: 0.75rem; - color: rgba(255, 255, 255, 0.4); - margin-top: 0.375rem; -} - -/* Save Button */ -.btn-save { - width: 100%; - background: linear-gradient(135deg, #0088cc 0%, #0066aa 100%); - border: none; - border-radius: 0.5rem; - padding: 0.75rem 1.5rem; - color: #fff; - font-size: 0.9rem; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; -} - -.btn-save:hover { - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0, 136, 204, 0.3); -} - -.btn-save:active { - transform: translateY(0); -} - -/* Status Card */ -.status-card { - display: flex; - flex-direction: column; - height: 100%; -} - -.status-card .webdav-card-body { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; -} - -/* Status Indicator */ -.status-indicator { - position: relative; - margin-bottom: 1.5rem; -} - -.status-circle { - width: 80px; - height: 80px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 2rem; - transition: all 0.3s ease; -} - -.status-circle.active { - background: rgba(16, 185, 129, 0.2); - border: 3px solid #10b981; - color: #10b981; - box-shadow: 0 0 30px rgba(16, 185, 129, 0.3); -} - -.status-circle.stopped { - background: rgba(239, 68, 68, 0.2); - border: 3px solid #ef4444; - color: #ef4444; - box-shadow: 0 0 30px rgba(239, 68, 68, 0.2); -} - -.status-circle.active::after { - content: ''; - position: absolute; - inset: -5px; - border-radius: 50%; - border: 2px solid rgba(16, 185, 129, 0.3); - animation: pulse-ring 2s ease-out infinite; -} - -@keyframes pulse-ring { - 0% { - transform: scale(1); - opacity: 1; - } - 100% { - transform: scale(1.3); - opacity: 0; - } -} - -/* Status Text */ -.status-text { - margin-bottom: 1.5rem; -} - -.status-text h4 { - margin: 0 0 0.25rem 0; - font-size: 1.25rem; - font-weight: 600; -} - -.status-text.active h4 { - color: #10b981; -} - -.status-text.stopped h4 { - color: #ef4444; -} - -.status-text p { - margin: 0; - font-size: 0.8rem; - color: rgba(255, 255, 255, 0.5); -} - -/* Control Buttons */ -.control-buttons { - display: flex; - gap: 0.75rem; - flex-wrap: wrap; - justify-content: center; -} - -.btn-control { - padding: 0.625rem 1.25rem; - border-radius: 0.5rem; - font-size: 0.85rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - display: flex; - align-items: center; - gap: 0.5rem; - border: none; -} - -.btn-control.start { - background: rgba(16, 185, 129, 0.2); - border: 1px solid rgba(16, 185, 129, 0.3); - color: #10b981; -} - -.btn-control.start:hover { - background: rgba(16, 185, 129, 0.3); - border-color: rgba(16, 185, 129, 0.5); - transform: translateY(-2px); -} - -.btn-control.stop { - background: rgba(245, 158, 11, 0.2); - border: 1px solid rgba(245, 158, 11, 0.3); - color: #f59e0b; -} - -.btn-control.stop:hover { - background: rgba(245, 158, 11, 0.3); - border-color: rgba(245, 158, 11, 0.5); - transform: translateY(-2px); -} - -.btn-control.reset { - background: rgba(239, 68, 68, 0.2); - border: 1px solid rgba(239, 68, 68, 0.3); - color: #ef4444; -} - -.btn-control.reset:hover { - background: rgba(239, 68, 68, 0.3); - border-color: rgba(239, 68, 68, 0.5); - transform: translateY(-2px); -} - -/* Connection Info */ -.connection-info { - margin-top: 1.5rem; - padding-top: 1.5rem; - border-top: 1px solid rgba(255, 255, 255, 0.1); - width: 100%; -} - -.connection-info h5 { - color: rgba(255, 255, 255, 0.6); - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 0.75rem; -} - -.connection-url { - background: rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 0.5rem; - padding: 0.75rem 1rem; - font-family: monospace; - font-size: 0.85rem; - color: #0088cc; - word-break: break-all; -} - -/* Info Box */ -.info-box { - background: rgba(0, 136, 204, 0.1); - border: 1px solid rgba(0, 136, 204, 0.2); - border-radius: 0.5rem; - padding: 1rem; - margin-top: 1.5rem; -} - -.info-box-header { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 0.5rem; -} - -.info-box-header i { - color: #0088cc; -} - -.info-box-header span { - color: #fff; - font-weight: 500; - font-size: 0.9rem; -} - -.info-box p { - margin: 0; - font-size: 0.8rem; - color: rgba(255, 255, 255, 0.6); - line-height: 1.5; -} diff --git a/TelegramDownloader/Program.cs b/TelegramDownloader/Program.cs index a5bcd0d..c557c55 100644 --- a/TelegramDownloader/Program.cs +++ b/TelegramDownloader/Program.cs @@ -150,6 +150,7 @@ // Progressive download service for streaming with background caching builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Task persistence services builder.Services.AddSingleton(); @@ -170,9 +171,15 @@ #pragma warning restore ASP0000 builder.Services.AddBlazorBootstrap(); -// Add controllers for Mobile API +// Add controllers for Mobile API and the modular v1 API builder.Services.AddControllers(); +// Modular v1 API: SignalR transfer hub + its supporting services +builder.Services.AddSignalR(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); + // CORS for PWA and mobile apps builder.Services.AddCors(options => { @@ -193,20 +200,48 @@ { Title = "TelegramFileManager Mobile API", Version = "v1", - Description = "REST API for mobile audio player application. Provides access to playlists, Telegram channels, file navigation and audio streaming.", + Description = "REST API for the mobile audio player application. Provides access to playlists, Telegram channels, file navigation and audio streaming.", + Contact = new Microsoft.OpenApi.Models.OpenApiContact + { + Name = "TFM" + } + }); + + c.SwaggerDoc("api-v1", new Microsoft.OpenApi.Models.OpenApiInfo + { + Title = "TelegramFileManager API v1", + Version = "1.0", + Description = + "Modular REST API exposing the full feature set of the web application: Telegram authentication, " + + "channel management, remote and local file management, transfers (downloads/uploads) with live " + + "progress over SignalR, playlists, sharing, configuration and system diagnostics.\n\n" + + "Live transfer progress is streamed over the SignalR hub at /hubs/transfers.", Contact = new Microsoft.OpenApi.Models.OpenApiContact { Name = "TFM" } }); - // Include only Mobile API controllers (FileController uses Syncfusion types that break Swagger) + // Route each controller to its document. FileController and the other legacy + // controllers use Syncfusion types that break schema generation, so they are + // excluded from both documents. c.DocInclusionPredicate((docName, apiDesc) => { + var route = apiDesc.RelativePath ?? string.Empty; var controllerName = apiDesc.ActionDescriptor.RouteValues["controller"]; + + if (docName == "api-v1") + return route.StartsWith("api/v1/", StringComparison.OrdinalIgnoreCase); + return controllerName?.StartsWith("Mobile") == true; }); + // Surface the XML doc comments written on the controllers and DTOs. + var xmlPath = Path.Combine(AppContext.BaseDirectory, + $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"); + if (File.Exists(xmlPath)) + c.IncludeXmlComments(xmlPath, includeControllerXmlComments: true); + // API Key authentication c.AddSecurityDefinition("ApiKey", new Microsoft.OpenApi.Models.OpenApiSecurityScheme { @@ -327,7 +362,8 @@ app.UseSwagger(); app.UseSwaggerUI(c => { - c.SwaggerEndpoint("/swagger/v1/swagger.json", "TFM Mobile API v1"); + c.SwaggerEndpoint("/swagger/api-v1/swagger.json", "TFM API v1 (full)"); + c.SwaggerEndpoint("/swagger/v1/swagger.json", "TFM Mobile API (audio player)"); c.RoutePrefix = "api-docs"; }); @@ -340,6 +376,9 @@ app.UseRouting(); app.MapControllers(); +// Live transfer progress for API clients (mobile apps, dashboards...) +app.MapHub("/hubs/transfers"); + app.MapBlazorHub(); app.MapFallbackToPage("/_Host"); diff --git a/TelegramDownloader/Services/Api/ApiUploadStaging.cs b/TelegramDownloader/Services/Api/ApiUploadStaging.cs new file mode 100644 index 0000000..27f5725 --- /dev/null +++ b/TelegramDownloader/Services/Api/ApiUploadStaging.cs @@ -0,0 +1,17 @@ +namespace TelegramDownloader.Services.Api +{ + /// + /// Where multipart uploads received by the API are staged before being + /// pushed to Telegram. + /// + /// The regular server-to-Telegram pipeline reads its sources from the local + /// root, so an uploaded body is written here first and then handed to that + /// pipeline. This keeps API uploads identical to web uploads in terms of + /// progress reporting, task persistence and resume-after-restart. + /// + public static class ApiUploadStaging + { + /// Folder name under the local root used for staged uploads. + public const string FolderName = ".api-uploads"; + } +} diff --git a/TelegramDownloader/Services/Api/ChannelFolderResolver.cs b/TelegramDownloader/Services/Api/ChannelFolderResolver.cs new file mode 100644 index 0000000..a43d0ae --- /dev/null +++ b/TelegramDownloader/Services/Api/ChannelFolderResolver.cs @@ -0,0 +1,110 @@ +using Syncfusion.Blazor.FileManager; +using TelegramDownloader.Data.db; +using TelegramDownloader.Models; + +namespace TelegramDownloader.Services.Api +{ + /// + /// Translates between the paths a REST client uses and the two path spaces + /// the channel index stores. + /// + /// Every indexed entry carries: + /// + /// FilterPath - the folder it lives in, ending with / (/music/rock/). + /// FilePath - its own full path, without a trailing slash (/music/rock/song.mp3). + /// + /// The root document is special: it is named Files and has all three + /// path fields empty, while its children use / as their folder path. + /// + public class ChannelFolderResolver + { + private readonly IDbService _db; + + public ChannelFolderResolver(IDbService db) + { + _db = db; + } + + /// Normalises a client-supplied folder path to the stored form. + public static string NormalizeFolderPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return "/"; + var p = path.Replace("\\", "/").Trim(); + if (!p.StartsWith('/')) p = "/" + p; + if (!p.EndsWith('/')) p += "/"; + while (p.Contains("//")) p = p.Replace("//", "/"); + return p; + } + + /// + /// Resolves the folder addressed by an id or a path. Returns the root + /// document when neither is supplied. + /// + public async Task ResolveFolder(string channelId, string? folderId, string? path, string? collectionId = null) + { + if (!string.IsNullOrWhiteSpace(folderId)) + { + var byId = await _db.getFileById(channelId, folderId, collectionId ?? "directory"); + if (byId != null && !byId.IsFile) return byId; + return byId; // caller decides how to treat a file id + } + + var folderPath = NormalizeFolderPath(path); + if (folderPath == "/") + return await _db.getRootFolder(channelId, collectionId ?? "directory"); + + // A folder's own FilePath has no trailing slash. + return await _db.getFileByPath(channelId, folderPath.TrimEnd('/'), collectionId ?? "directory"); + } + + /// + /// Folder path used by the children of , i.e. + /// the value stored in their FilterPath. + /// + public static string ChildFolderPath(BsonFileManagerModel folder) + { + if (string.IsNullOrEmpty(folder.FilePath)) return "/"; + return folder.FilePath.EndsWith('/') ? folder.FilePath : folder.FilePath + "/"; + } + + /// + /// Value to pass as the path argument when creating a child of + /// . + /// + public static string CreateChildPath(BsonFileManagerModel folder) => + string.IsNullOrEmpty(folder.FilePath) ? "/" : folder.FilePath; + + /// Lists the direct children of a folder. + public async Task> ListChildren(string channelId, BsonFileManagerModel folder, string? collectionId = null) + { + var childPath = ChildFolderPath(folder); + var items = await _db.getAllFilesInDirectoryPath(channelId, childPath, collectionId ?? "directory"); + return items ?? new List(); + } + + /// + /// Builds the breadcrumb from the channel root down to + /// , inclusive. + /// + public static List<(string Name, string Path)> Breadcrumbs(BsonFileManagerModel folder) + { + var crumbs = new List<(string, string)> { ("Files", "/") }; + var path = ChildFolderPath(folder); + if (path == "/") return crumbs.Select(c => (c.Item1, c.Item2)).ToList(); + + var acc = "/"; + foreach (var segment in path.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + acc += segment + "/"; + crumbs.Add((segment, acc)); + } + return crumbs; + } + + /// + /// Converts a stored entry into the Syncfusion shape the existing + /// IFileService mutation methods expect. + /// + public static FileManagerDirectoryContent ToContent(BsonFileManagerModel m) => m.toFileManagerContent(); + } +} diff --git a/TelegramDownloader/Services/Api/QrLoginSessionManager.cs b/TelegramDownloader/Services/Api/QrLoginSessionManager.cs new file mode 100644 index 0000000..836368e --- /dev/null +++ b/TelegramDownloader/Services/Api/QrLoginSessionManager.cs @@ -0,0 +1,187 @@ +using System.Collections.Concurrent; +using QRCoder; +using TelegramDownloader.Data; +using TelegramDownloader.Models.Api; + +namespace TelegramDownloader.Services.Api +{ + /// + /// Keeps the state of QR login attempts started through the REST API. + /// + /// The Telegram QR flow is long-lived and callback based: the library hands + /// out a fresh tg://login URL every ~30s and, if the account has + /// two-factor authentication, asks for the password after the phone accepts + /// the code. A mobile client cannot hold that callback, so a session is kept + /// server-side and polled through + /// GET /api/v1/auth/qr/{sessionId}. + /// + public class QrLoginSessionManager : IDisposable + { + /// Sessions with no polling for this long are discarded. + public static readonly TimeSpan SessionLifetime = TimeSpan.FromMinutes(10); + + private readonly ConcurrentDictionary _sessions = new(); + private readonly ILogger _logger; + + public QrLoginSessionManager(ILogger logger) + { + _logger = logger; + } + + /// + /// Starts a QR login in the background and returns the session as soon as + /// the first QR URL is available (or the timeout elapses). + /// + public async Task StartAsync(ITelegramService telegram, bool logoutFirst = false) + { + PruneExpired(); + + var session = new QrSession(); + _sessions[session.Id] = session; + + void OnPasswordNeeded(object? sender, EventArgs e) + { + session.Status = "password_required"; + session.LoginUrl = null; + session.QrImageBase64 = null; + } + + TelegramService.QrPasswordNeeded += OnPasswordNeeded; + + session.Worker = Task.Run(async () => + { + try + { + var user = await telegram.CallQrGenerator( + url => + { + session.LoginUrl = url; + session.QrImageBase64 = RenderQr(url); + session.Touch(); + }, + session.Cancellation.Token, + logoutFirst); + + session.Status = user != null ? "authenticated" : "error"; + if (user == null) + session.Error = "Telegram did not return a user"; + } + catch (OperationCanceledException) + { + session.Status = "cancelled"; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "QR login session {SessionId} failed", session.Id); + session.Status = "error"; + session.Error = ex.Message; + } + finally + { + TelegramService.QrPasswordNeeded -= OnPasswordNeeded; + } + }); + + // Give the library a moment to emit the first URL so the very first + // response already carries a QR the client can render. + var deadline = DateTime.UtcNow.AddSeconds(10); + while (session.LoginUrl == null && session.Status == "waiting" && DateTime.UtcNow < deadline) + await Task.Delay(100); + + return session.ToDto(); + } + + /// Returns the current state of a session, or null when unknown. + public QrLoginDto? Get(string sessionId) + { + PruneExpired(); + if (!_sessions.TryGetValue(sessionId, out var session)) return null; + session.Touch(); + return session.ToDto(); + } + + /// + /// Supplies the two-factor password a session is waiting for. Returns + /// false when the session does not exist. + /// + public bool ProvidePassword(string sessionId, ITelegramService telegram, string password) + { + if (!_sessions.TryGetValue(sessionId, out var session)) return false; + session.Touch(); + telegram.ProvideQrLoginPassword(password); + session.Status = "waiting"; + return true; + } + + /// Cancels a pending session. Returns false when unknown. + public bool Cancel(string sessionId) + { + if (!_sessions.TryRemove(sessionId, out var session)) return false; + session.Cancel(); + return true; + } + + private void PruneExpired() + { + var cutoff = DateTime.UtcNow - SessionLifetime; + foreach (var kvp in _sessions) + { + if (kvp.Value.LastSeenUtc < cutoff) + { + if (_sessions.TryRemove(kvp.Key, out var stale)) + stale.Cancel(); + } + } + } + + private static string RenderQr(string data) + { + using var generator = new QRCodeGenerator(); + using var qrData = generator.CreateQrCode(data, QRCodeGenerator.ECCLevel.Q); + using var png = new PngByteQRCode(qrData); + return Convert.ToBase64String(png.GetGraphic(20)); + } + + public void Dispose() + { + foreach (var session in _sessions.Values) + session.Cancel(); + _sessions.Clear(); + GC.SuppressFinalize(this); + } + + private class QrSession + { + public string Id { get; } = Guid.NewGuid().ToString("N"); + public CancellationTokenSource Cancellation { get; } = new(); + public Task? Worker { get; set; } + public string Status { get; set; } = "waiting"; + public string? LoginUrl { get; set; } + public string? QrImageBase64 { get; set; } + public string? Error { get; set; } + public DateTime LastSeenUtc { get; private set; } = DateTime.UtcNow; + + public void Touch() => LastSeenUtc = DateTime.UtcNow; + + public void Cancel() + { + try + { + if (!Cancellation.IsCancellationRequested) + Cancellation.Cancel(); + } + catch (ObjectDisposedException) { } + Status = Status == "authenticated" ? Status : "cancelled"; + } + + public QrLoginDto ToDto() => new() + { + SessionId = Id, + LoginUrl = LoginUrl, + QrImageBase64 = QrImageBase64, + Status = Status, + Error = Error + }; + } + } +} diff --git a/TelegramDownloader/Services/Api/TransferBroadcastService.cs b/TelegramDownloader/Services/Api/TransferBroadcastService.cs new file mode 100644 index 0000000..3a14875 --- /dev/null +++ b/TelegramDownloader/Services/Api/TransferBroadcastService.cs @@ -0,0 +1,152 @@ +using Microsoft.AspNetCore.SignalR; +using TelegramDownloader.Hubs; +using TelegramDownloader.Models.Api; + +namespace TelegramDownloader.Services.Api +{ + /// + /// Bridges the in-process events to the + /// so REST clients and mobile apps get live + /// download/upload progress without polling. + /// + /// Progress callbacks fire per network chunk, so snapshots are coalesced to + /// at most one every with a guaranteed + /// trailing push; the much cheaper summary message is sent on every change. + /// + public class TransferBroadcastService : IHostedService, IDisposable + { + /// Minimum interval between two full snapshot pushes. + public static readonly TimeSpan SnapshotThrottle = TimeSpan.FromMilliseconds(500); + + private readonly TransactionInfoService _tis; + private readonly IHubContext _hub; + private readonly ILogger _logger; + + private readonly object _gate = new(); + private DateTime _lastSnapshotUtc = DateTime.MinValue; + private bool _trailingScheduled; + private Timer? _trailingTimer; + private bool _disposed; + + public TransferBroadcastService( + TransactionInfoService tis, + IHubContext hub, + ILogger logger) + { + _tis = tis; + _hub = hub; + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _tis.TransactionsChanged += OnTransactionsChanged; + _tis.TaskEventChanged += OnTaskEventChanged; + _tis.NewSpeedHistoryPoint += OnNewSpeedHistoryPoint; + _logger.LogInformation("TransferBroadcastService started - streaming transfer updates on {Path}", "/hubs/transfers"); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _tis.TransactionsChanged -= OnTransactionsChanged; + _tis.TaskEventChanged -= OnTaskEventChanged; + _tis.NewSpeedHistoryPoint -= OnNewSpeedHistoryPoint; + return Task.CompletedTask; + } + + private void OnTransactionsChanged(object? sender, EventArgs e) => ScheduleSnapshot(); + + private void OnTaskEventChanged(object? sender, EventArgs e) => _ = SendSummaryAsync(); + + private void OnNewSpeedHistoryPoint(object? sender, SpeedHistoryEventArgs e) + { + _ = SafeSend(async () => + { + await _hub.Clients.Group(TransferHub.SpeedGroup).SendAsync( + TransferHub.SpeedPointMessage, + SpeedPointDto.From(e.DownloadPoint), + SpeedPointDto.From(e.UploadPoint)); + }); + } + + /// + /// Pushes a snapshot now when the throttle window is open, otherwise + /// arms a trailing push so the last change in a burst is never lost. + /// + private void ScheduleSnapshot() + { + bool sendNow = false; + lock (_gate) + { + if (_disposed) return; + var now = DateTime.UtcNow; + if (now - _lastSnapshotUtc >= SnapshotThrottle) + { + _lastSnapshotUtc = now; + sendNow = true; + } + else if (!_trailingScheduled) + { + _trailingScheduled = true; + var delay = SnapshotThrottle - (now - _lastSnapshotUtc); + if (delay < TimeSpan.Zero) delay = TimeSpan.Zero; + if (_trailingTimer == null) + _trailingTimer = new Timer(_ => SendTrailingSnapshot(), null, delay, Timeout.InfiniteTimeSpan); + else + _trailingTimer.Change(delay, Timeout.InfiniteTimeSpan); + } + } + + if (sendNow) + _ = SendSnapshotAsync(); + } + + private void SendTrailingSnapshot() + { + lock (_gate) + { + _trailingScheduled = false; + _lastSnapshotUtc = DateTime.UtcNow; + } + _ = SendSnapshotAsync(); + } + + private Task SendSnapshotAsync() => SafeSend(async () => + { + var snapshot = TransferSnapshotBuilder.BuildSnapshot(_tis); + await _hub.Clients.Group(TransferHub.SnapshotGroup).SendAsync(TransferHub.SnapshotMessage, snapshot); + await _hub.Clients.Group(TransferHub.SummaryGroup).SendAsync(TransferHub.SummaryMessage, snapshot.Summary); + }); + + private Task SendSummaryAsync() => SafeSend(async () => + { + var summary = TransferSnapshotBuilder.BuildSummary(_tis); + await _hub.Clients.Group(TransferHub.SummaryGroup).SendAsync(TransferHub.SummaryMessage, summary); + }); + + private async Task SafeSend(Func send) + { + try + { + await send(); + } + catch (Exception ex) + { + // A broken client connection must never break a transfer. + _logger.LogDebug(ex, "Failed to broadcast a transfer update"); + } + } + + public void Dispose() + { + lock (_gate) + { + _disposed = true; + _trailingTimer?.Dispose(); + _trailingTimer = null; + } + GC.SuppressFinalize(this); + } + } +} diff --git a/TelegramDownloader/Services/Api/TransferSnapshotBuilder.cs b/TelegramDownloader/Services/Api/TransferSnapshotBuilder.cs new file mode 100644 index 0000000..2eb112f --- /dev/null +++ b/TelegramDownloader/Services/Api/TransferSnapshotBuilder.cs @@ -0,0 +1,84 @@ +using TelegramDownloader.Models; +using TelegramDownloader.Models.Api; + +namespace TelegramDownloader.Services.Api +{ + /// + /// Builds the DTOs shared by GET /api/v1/transfers and the + /// transfers SignalR hub, so REST snapshots and live pushes always + /// have exactly the same shape. + /// + public static class TransferSnapshotBuilder + { + /// Full picture of active and queued transfers plus the summary. + public static TransfersSnapshotDto BuildSnapshot(TransactionInfoService tis) + { + return new TransfersSnapshotDto + { + Downloads = tis.downloadModels.ToList() + .Select(d => TransferDto.FromDownload(d)).ToList(), + QueuedDownloads = tis.pendingDownloadModels.ToList() + .Select(d => TransferDto.FromDownload(d, isQueued: true)).ToList(), + Uploads = tis.uploadModels.ToList() + .Select(u => TransferDto.FromUpload(u)).ToList(), + QueuedUploads = tis.pendingUploadModels.ToList() + .Select(u => TransferDto.FromUpload(u, isQueued: true)).ToList(), + Tasks = tis.infoDownloadTaksModel.ToList() + .OrderBy(t => t.creationDate) + .Select(TransferDto.FromBatch).ToList(), + Summary = BuildSummary(tis) + }; + } + + /// Counters and current speeds only. + public static TransferSummaryDto BuildSummary(TransactionInfoService tis) + { + var downloads = tis.downloadModels.ToList(); + var uploads = tis.uploadModels.ToList(); + var tasks = tis.infoDownloadTaksModel.ToList(); + + return new TransferSummaryDto + { + ActiveDownloads = downloads.Count(d => d.state == StateTask.Working), + QueuedDownloads = tis.pendingDownloadModels.Count, + ActiveUploads = uploads.Count(u => u.state == StateTask.Working), + QueuedUploads = tis.pendingUploadModels.Count, + ActiveTasks = tasks.Count(t => t.state == StateTask.Working), + TotalTasks = tasks.Count, + DownloadSpeed = tis.downloadSpeed ?? "0 KB/s", + UploadSpeed = tis.uploadSpeed ?? "0 KB/s", + DownloadBytesPerSecond = tis.bytesDownloaded, + UploadBytesPerSecond = tis.bytesUploaded, + DownloadsPaused = tis.isPauseDownloads + }; + } + + /// Speed history for the charts, newest last. + public static SpeedHistoryDto BuildSpeedHistory(TransactionInfoService tis) + { + return new SpeedHistoryDto + { + Download = tis.GetDownloadSpeedsHistoryCopy().Select(SpeedPointDto.From).ToList(), + Upload = tis.GetUploadSpeedsHistoryCopy().Select(SpeedPointDto.From).ToList() + }; + } + + /// + /// Finds a running or queued transfer by its id across every list. + /// + public static bool TryFind( + TransactionInfoService tis, + string id, + out DownloadModel? download, + out UploadModel? upload, + out InfoDownloadTaksModel? task) + { + download = tis.downloadModels.FirstOrDefault(d => d._internalId == id) + ?? tis.pendingDownloadModels.FirstOrDefault(d => d._internalId == id); + upload = tis.uploadModels.FirstOrDefault(u => u._internalId == id) + ?? tis.pendingUploadModels.FirstOrDefault(u => u._internalId == id); + task = tis.infoDownloadTaksModel.FirstOrDefault(t => t._internalId == id); + return download != null || upload != null || task != null; + } + } +} diff --git a/TelegramDownloader/Services/WebDavLockManager.cs b/TelegramDownloader/Services/WebDavLockManager.cs new file mode 100644 index 0000000..9bbb380 --- /dev/null +++ b/TelegramDownloader/Services/WebDavLockManager.cs @@ -0,0 +1,87 @@ +using System.Collections.Concurrent; + +namespace TelegramDownloader.Services +{ + /// + /// Minimal in-memory WebDAV lock registry (class 2). It grants, refreshes and + /// releases exclusive write-lock tokens so clients that require the LOCK + /// handshake before writing (some Hyper Backup / WebDAV configurations) can + /// proceed. + /// + /// Locks are advisory: a conflicting LOCK on an already-locked resource returns + /// 423, but writes are NOT gated on presenting the token. For a single-writer + /// backup target that is enough, and it avoids breaking clients on the many + /// edge cases of strict If:-header enforcement. Registered as a singleton. + /// + public class WebDavLockManager + { + private sealed class LockEntry + { + public string Token = string.Empty; + public DateTime ExpiresUtc; + public string? Owner; + } + + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromHours(1); + private static readonly TimeSpan MaxTimeout = TimeSpan.FromHours(24); + + private readonly ConcurrentDictionary _locks = new(); + + /// Keeps a requested timeout within sane bounds (default 1h, max 24h). + public TimeSpan ClampTimeout(TimeSpan? requested) + { + if (!requested.HasValue || requested.Value <= TimeSpan.Zero) return DefaultTimeout; + return requested.Value > MaxTimeout ? MaxTimeout : requested.Value; + } + + /// + /// Tries to acquire an exclusive lock on . Returns the + /// new token, or null when the resource is already locked by someone else. + /// + public string? TryAcquire(string key, string? owner, TimeSpan timeout) + { + var now = DateTime.UtcNow; + var token = "opaquelocktoken:" + Guid.NewGuid().ToString(); + var entry = new LockEntry { Token = token, ExpiresUtc = now.Add(timeout), Owner = owner }; + + while (true) + { + if (_locks.TryGetValue(key, out var existing)) + { + if (existing.ExpiresUtc > now) + return null; // still held by someone else + if (_locks.TryUpdate(key, entry, existing)) return token; // replace expired + continue; // lost a race, retry + } + if (_locks.TryAdd(key, entry)) return token; + } + } + + /// Refreshes an existing lock if the token matches. Returns true on success. + public bool Refresh(string key, string token, TimeSpan timeout) + { + if (_locks.TryGetValue(key, out var e) && e.Token == token) + { + e.ExpiresUtc = DateTime.UtcNow.Add(timeout); + return true; + } + return false; + } + + /// Releases a lock if the token matches. Returns true if a lock was removed. + public bool Release(string key, string token) + { + if (_locks.TryGetValue(key, out var e) && e.Token == token) + return _locks.TryRemove(key, out _); + return false; + } + + /// Returns the active (unexpired) token for a resource, or null. + public string? GetActiveToken(string key) + { + if (_locks.TryGetValue(key, out var e) && e.ExpiresUtc > DateTime.UtcNow) + return e.Token; + return null; + } + } +} diff --git a/TelegramDownloader/Services/WebDavService.cs b/TelegramDownloader/Services/WebDavService.cs deleted file mode 100644 index 3cb903d..0000000 --- a/TelegramDownloader/Services/WebDavService.cs +++ /dev/null @@ -1,51 +0,0 @@ -ο»Ώnamespace TelegramDownloader.Services -{ - using System.Diagnostics; - - public class WebbDavService - { - private static Process? _pythonProcess; - - public void Start(string scriptPath = "WebDav/webdav_api_proxy.py", int port = 8000, int externalPort = 9081, string host = "127.0.0.1") - { - if (_pythonProcess != null && !_pythonProcess.HasExited) - return; // ya estΓ‘ corriendo - - var startInfo = new ProcessStartInfo - { - FileName = "python", // o "python3" segΓΊn tu sistema - Arguments = $"{scriptPath} --port {port} --out-port {externalPort} --host {host}", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - _pythonProcess = new Process { StartInfo = startInfo }; - _pythonProcess.OutputDataReceived += (s, e) => Console.WriteLine(e.Data); - _pythonProcess.ErrorDataReceived += (s, e) => Console.WriteLine(e.Data); - - _pythonProcess.Start(); - _pythonProcess.BeginOutputReadLine(); - _pythonProcess.BeginErrorReadLine(); - } - - public void Stop() - { - if (_pythonProcess != null && !_pythonProcess.HasExited) - { - _pythonProcess.Kill(); - _pythonProcess.Dispose(); - _pythonProcess = null; - } - } - - public void Restart(string scriptPath, int port = 8000) - { - Stop(); - Start(scriptPath, port); - } - - public bool IsRunning => _pythonProcess != null && !_pythonProcess.HasExited; - } -} diff --git a/TelegramDownloader/Shared/ConfigLayout.razor b/TelegramDownloader/Shared/ConfigLayout.razor index 8857166..5549ef5 100644 --- a/TelegramDownloader/Shared/ConfigLayout.razor +++ b/TelegramDownloader/Shared/ConfigLayout.razor @@ -55,7 +55,6 @@
Configuration - WebDav Tasks Logs LogOut diff --git a/TelegramDownloader/Shared/MainLayout.razor b/TelegramDownloader/Shared/MainLayout.razor index 7c85991..94f6587 100644 --- a/TelegramDownloader/Shared/MainLayout.razor +++ b/TelegramDownloader/Shared/MainLayout.razor @@ -166,11 +166,6 @@ Configuration - +