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/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..cba2dec --- /dev/null +++ b/TelegramDownloader/Controllers/Api/V1/ChannelsController.cs @@ -0,0 +1,553 @@ +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. + /// 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] string? search = null) + { + try + { + var chats = onlySaved + ? await _telegram.getAllSavedChats() + : await _telegram.getAllChats(); + + var favourites = GeneralConfigStatic.config.FavouriteChannels ?? new List(); + var items = (chats ?? new List()) + .Where(c => c?.chat != null) + .Select(c => ApiChannelDto.FromChatViewBase( + c, + isFavorite: favourites.Contains(c.chat.ID), + isOwner: SafeIsOwner(c.chat.ID))) + .ToList(); + + if (favoritesOnly) + items = items.Where(c => c.IsFavorite).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 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(c => c?.chat != null) + .Select(c => ApiChannelDto.FromChatViewBase(c, favourites.Contains(c.chat.ID), SafeIsOwner(c.chat.ID))) + .ToList() + }).ToList(), + Ungrouped = (data?.UngroupedChats ?? new List()) + .Where(c => c?.chat != null) + .Select(c => ApiChannelDto.FromChatViewBase(c, favourites.Contains(c.chat.ID), SafeIsOwner(c.chat.ID))) + .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 items = (chats ?? new List()) + .Where(c => c?.chat != null) + .Select(c => ApiChannelDto.FromChatViewBase(c, isFavorite: true, isOwner: SafeIsOwner(c.chat.ID))) + .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); + } + } + + /// 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), + 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; } + } + + 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..221920d --- /dev/null +++ b/TelegramDownloader/Controllers/Api/V1/ConfigController.cs @@ -0,0 +1,160 @@ +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.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; + + c.webDav ??= new WebDavModel(); + if (!string.IsNullOrWhiteSpace(request.WebDavHost)) c.webDav.Host = request.WebDavHost; + if (request.WebDavInternalPort.HasValue) c.webDav.PuertoEntrada = request.WebDavInternalPort.Value; + if (request.WebDavExternalPort.HasValue) c.webDav.PuertoSalida = request.WebDavExternalPort.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); + } + } + + /// State of the WebDAV bridge. + [HttpGet("webdav")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult WebDav() => OkResult(AppConfigDto.From(GeneralConfigStatic.config).WebDav); + + /// Starts the WebDAV bridge. + /// + /// Once running, channels are reachable as WebDAV shares at + /// http://<host>:<externalPort>/<channelId>/, + /// which is how media servers mount a library. + /// + [HttpPost("webdav/start")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult StartWebDav() + { + try + { + var webDav = GeneralConfigStatic.config.webDav; + if (webDav == null) + return BadRequestResult("WebDAV is not configured"); + + if (webDav.webDavService?.IsRunning == true) + return ConflictResult("The WebDAV bridge is already running", ApiErrorCodes.AlreadyRunning); + + webDav.start(); + return OkResult(AppConfigDto.From(GeneralConfigStatic.config).WebDav, "WebDAV bridge started"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting the WebDAV bridge"); + return ErrorResult("Could not start the WebDAV bridge", ex); + } + } + + /// Stops the WebDAV bridge. + [HttpPost("webdav/stop")] + [ProducesResponseType(typeof(ApiResult), StatusCodes.Status200OK)] + public IActionResult StopWebDav() + { + try + { + GeneralConfigStatic.config.webDav?.stop(); + return OkResult(AppConfigDto.From(GeneralConfigStatic.config).WebDav, "WebDAV bridge stopped"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error stopping the WebDAV bridge"); + return ErrorResult("Could not stop the WebDAV bridge", 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..5276c4e --- /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.tlconfig?.mobile_api_key), + WebDavRunning = GeneralConfigStatic.config?.webDav?.webDavService?.IsRunning ?? false + }; + + 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/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..7b10110 100644 --- a/TelegramDownloader/Middleware/ApiKeyMiddleware.cs +++ b/TelegramDownloader/Middleware/ApiKeyMiddleware.cs @@ -17,10 +17,20 @@ 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; @@ -44,36 +54,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 +96,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..083b86a --- /dev/null +++ b/TelegramDownloader/Models/Api/ChannelDtos.cs @@ -0,0 +1,155 @@ +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; } + + /// 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) + { + 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, + 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..aba8864 --- /dev/null +++ b/TelegramDownloader/Models/Api/SystemDtos.cs @@ -0,0 +1,288 @@ +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; } + public bool WebDavRunning { 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 List FavouriteChannels { 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 WebDavConfigDto WebDav { get; set; } = new(); + + 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, + FavouriteChannels = c.FavouriteChannels ?? 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, + WebDav = new WebDavConfigDto + { + Host = c.webDav?.Host ?? "127.0.0.1", + InternalPort = c.webDav?.PuertoEntrada ?? 0, + ExternalPort = c.webDav?.PuertoSalida ?? 0, + IsRunning = c.webDav?.webDavService?.IsRunning ?? false + } + }; + } + + /// WebDAV bridge settings and state. + public class WebDavConfigDto + { + public string Host { get; set; } = "127.0.0.1"; + public int InternalPort { get; set; } + public int ExternalPort { get; set; } + public bool IsRunning { get; set; } + } + + /// + /// 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? 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 string? WebDavHost { get; set; } + public int? WebDavInternalPort { get; set; } + public int? WebDavExternalPort { 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/Program.cs b/TelegramDownloader/Program.cs index a5bcd0d..34b4a9c 100644 --- a/TelegramDownloader/Program.cs +++ b/TelegramDownloader/Program.cs @@ -170,9 +170,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 +199,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 +361,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 +375,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/TelegramDownloader.csproj b/TelegramDownloader/TelegramDownloader.csproj index 21c002f..fd17502 100644 --- a/TelegramDownloader/TelegramDownloader.csproj +++ b/TelegramDownloader/TelegramDownloader.csproj @@ -10,6 +10,8 @@ 528ba20b-8482-4c0f-9c2f-7ecc8dc30410 Linux . + + true @@ -17,7 +19,7 @@ - CS1998;CS4014;CA2024;CA2200;CS0618;SYSLIB0014;ASPDEPR005;CS0649;CS8600;CS8602;CS8603;CS8604;CS8605;CS8618;CS8622;CS8625;CS8632;CS8669 + CS1998;CS4014;CA2024;CA2200;CS0618;SYSLIB0014;ASPDEPR005;CS0649;CS8600;CS8602;CS8603;CS8604;CS8605;CS8618;CS8622;CS8625;CS8632;CS8669;CS1591 diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..4377423 --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,84 @@ +# TelegramFileManager API v1 + +A modular REST + SignalR API that exposes the **complete feature set of the web +application** so it can be driven from a mobile app (or any other client): sign +in to Telegram, browse and manage the files stored in your channels, browse the +server's local storage, move bytes in and out of Telegram with **live progress +over SignalR**, manage playlists, share libraries, and read/change the server +configuration. + +This API lives side by side with the app's existing surfaces: + +| Surface | Base path | Purpose | +| --- | --- | --- | +| **API v1** (this document) | `/api/v1` | Full-featured, versioned API for app clients. | +| Legacy mobile API | `/api/mobile` | The narrower API the current audio player uses. Untouched. | +| Web UI | `/` | The Blazor Server application. | + +--- + +## Documentation index + +| Document | What it covers | +| --- | --- | +| [getting-started.md](getting-started.md) | Base URL, API keys, the response envelope, error codes, paging, a first end-to-end walkthrough. | +| [authentication.md](authentication.md) | Phone login, QR login, 2FA, sessions, signing out. | +| [channels.md](channels.md) | Listing chats, folders, favourites, creating/leaving channels, indexing (refresh), message history, avatars, invitations. | +| [files.md](files.md) | Browsing, searching, folder/rename/delete/copy/move, direct upload, export/import of a channel index. | +| [transfers.md](transfers.md) | Downloads, uploads, message downloads, queue control, persisted tasks. The heart of the API. | +| [signalr.md](signalr.md) | The `/hubs/transfers` real-time hub: messages, client methods, reconnection, sample clients. | +| [local-files.md](local-files.md) | Browsing and managing the server's local storage, the streaming cache. | +| [playlists.md](playlists.md) | Playlists mixing Telegram and local tracks, reordering, bulk download. | +| [shares.md](shares.md) | Sharing a channel folder and importing a share, `.strm` export for media servers. | +| [system-and-config.md](system-and-config.md) | Health, metrics, logs, database maintenance, application settings, WebDAV bridge. | +| [reference.md](reference.md) | Full endpoint table and the data models returned by the API. | + +## Interactive documentation (Swagger / OpenAPI) + +The server serves a live, browsable OpenAPI document. With the app running: + +- Swagger UI: **`/api-docs`** β€” pick **"TFM API v1 (full)"** from the definition selector. +- Raw OpenAPI JSON: **`/swagger/api-v1/swagger.json`** + +The XML doc comments on every endpoint and DTO are compiled into that document, +so the descriptions you see there match this written documentation. You can feed +`/swagger/api-v1/swagger.json` to a code generator (`openapi-generator`, +`NSwag`, `swagger-codegen`, Kiota…) to produce a typed client for your mobile +platform. + +## The 30-second tour + +``` +# 1. Is the server up and is my key valid? +GET /api/v1/system/ping -> "pong" + +# 2. What state is everything in? +GET /api/v1/system/info -> versions, setup, auth, hub path + +# 3. Sign in (once; the session is shared and long-lived) +POST /api/v1/auth/login {phone} -> step "vc" +POST /api/v1/auth/login {code} -> step "ok" + +# 4. Find a channel and browse it +GET /api/v1/channels?onlySaved=true +GET /api/v1/channels/{id}/files?path=/ + +# 5. Download a file to the server, and watch it live +POST /api/v1/transfers/downloads {channelId, fileIds} + (connect to the SignalR hub /hubs/transfers for progress) +``` + +## Design principles + +- **One envelope everywhere.** Every JSON response is + `{ success, data, error, message, page }`. See + [getting-started.md](getting-started.md#response-envelope). +- **Machine-readable errors.** `error.code` is a stable slug + (`channel_not_found`, `not_logged_in`, …) you can branch on. +- **Non-blocking transfers.** Anything that moves bytes returns immediately with + `202 Accepted`; progress arrives on the SignalR hub. +- **Paths, not opaque handles.** Folders are addressed by human-readable path + (`/music/rock/`) as well as by id, so URLs are debuggable. +- **Shared state.** The Telegram session, the transfer queue and the + configuration are the same objects the web UI uses. A file downloaded from the + API shows up in the web UI and vice versa. diff --git a/docs/api/authentication.md b/docs/api/authentication.md new file mode 100644 index 0000000..52f31ba --- /dev/null +++ b/docs/api/authentication.md @@ -0,0 +1,233 @@ +# Authentication + +There are two separate things called "authentication" in this app; keep them +distinct: + +- **API key** β€” the shared secret every request must carry. Covered in + [getting-started.md](getting-started.md#authentication-the-api-key). +- **Telegram session** β€” the signed-in Telegram account. This page is about + that. + +The Telegram session is **shared and server-side**. There is one session per +server instance, used by the web UI and every API client at once. Signing in +through the API signs in the web UI too; signing out ends it for everyone. The +session is persisted to disk, so it survives restarts and you normally sign in +only once. + +Endpoints that need an account (channels, files, transfers, shares) return +`401 not_logged_in` when there is no session, or `503 setup_required` when the +app has not been configured. Read state (`/api/v1/transfers`, `/api/v1/system/*`, +`/api/v1/config`, `/api/v1/local/*`, `/api/v1/playlists`) does **not** require a +session. + +--- + +## Check the current state + +``` +GET /api/v1/auth/status +``` + +```json +{ + "success": true, + "data": { + "step": "phone", + "isAuthenticated": false, + "isConfigured": true, + "user": null + } +} +``` + +`step` is the state machine that drives login: + +| `step` | Meaning | What to send next | +| --- | --- | --- | +| `phone` | No session. | The phone number (`POST /auth/login`, `isPhone:true`). | +| `vc` | Waiting for the code Telegram sent. | The verification code. | +| `pass` | Account has 2FA; waiting for the password. | The 2FA password. | +| `ok` | Signed in. | Nothing β€” you're done. | +| `setup_required` | App not configured. | Finish setup; see [system-and-config.md](system-and-config.md). | + +When `step` is `ok`, `data.user` holds the signed-in account: + +```json +{ + "id": 123456789, + "username": "alice", + "firstName": "Alice", + "lastName": null, + "phone": "34600000000", + "isPremium": true +} +``` + +`isPremium` matters because it raises the per-file upload limit from 2 GB to +4 GB. + +--- + +## Phone login + +A short state machine. Post one value at a time and follow `step`. + +### 1. Phone number + +``` +POST /api/v1/auth/login +Content-Type: application/json + +{ "value": "+34600000000", "isPhone": true } +``` + +`isPhone: true` tells the server this is a phone number, so it starts a fresh +login and asks Telegram to send a code. Response `step` becomes `vc`. + +### 2. Verification code + +``` +POST /api/v1/auth/login +{ "value": "12345" } +``` + +Response `step` becomes either `ok` (no 2FA) or `pass` (2FA enabled). + +### 3. Two-factor password (only if `step` == `pass`) + +``` +POST /api/v1/auth/login +{ "value": "my-2fa-password" } +``` + +Response `step` becomes `ok` and `data.user` is populated. + +A rejected value (wrong code, wrong password) comes back as +`400 invalid_request` with the Telegram error in `error.detail`; the `step` does +not advance, so simply prompt again. + +--- + +## QR login + +Lets the user sign in by scanning a code with the Telegram app on their phone β€” +no phone number typed on the client. This is the smoothest flow for a mobile +app, because the QR can be scanned from another device or, if the app *is* the +phone, deep-linked. + +The Telegram QR flow is long-lived and callback-based (Telegram rotates the +token roughly every 30 seconds and, for 2FA accounts, asks for the password +*after* the phone accepts). The server holds that flow in a **QR session** you +poll. + +### 1. Start a session + +``` +POST /api/v1/auth/qr +``` + +Optional `?logoutFirst=true` ends any existing session first. + +```json +{ + "success": true, + "data": { + "sessionId": "9f2c…", + "loginUrl": "tg://login?token=BASE64", + "qrImageBase64": "iVBORw0KGgo…", + "status": "waiting", + "error": null + } +} +``` + +Render `qrImageBase64` directly (``), or +encode `loginUrl` into a QR yourself for full control over styling. + +### 2. Poll the session + +``` +GET /api/v1/auth/qr/{sessionId} +``` + +Poll every ~2 seconds. `status` transitions through: + +| `status` | Meaning | Action | +| --- | --- | --- | +| `waiting` | Not scanned yet. Telegram may have rotated the token β€” repaint from the fresh `loginUrl`/`qrImageBase64`. | Keep polling. | +| `password_required` | Scanned, but the account has 2FA. | Prompt for the password and `POST …/password`. | +| `authenticated` | Signed in. | Stop polling. | +| `cancelled` | The session was cancelled or expired. | Start a new one. | +| `error` | Failed; see `error`. | Start a new one. | + +Sessions with no polling for 10 minutes are discarded. + +### 3. 2FA password (only if `status` == `password_required`) + +``` +POST /api/v1/auth/qr/{sessionId}/password +{ "password": "my-2fa-password" } +``` + +Then keep polling until `authenticated`. + +### 4. Cancel (optional) + +``` +DELETE /api/v1/auth/qr/{sessionId} +``` + +Call this if the user backs out of the QR screen, to free the pending login. + +--- + +## Who am I + +``` +GET /api/v1/auth/me +``` + +Returns the signed-in user, or `401 not_logged_in` when there is no session. +Useful right after login to show the account, and as a cheap session check. + +--- + +## Sign out + +``` +POST /api/v1/auth/logout +``` + +Terminates the shared session. The web UI and every other client will need to +authenticate again. There is normally no reason to call this from a mobile app +unless the user explicitly wants to disconnect the account from the server. + +--- + +## Client recipe + +```ts +async function ensureSignedIn(api) { + const { step, user } = await api.get("/api/v1/auth/status"); + if (step === "ok") return user; + if (step === "setup_required") throw new Error("Server needs setup"); + + // Prefer QR on mobile: + const qr = await api.post("/api/v1/auth/qr"); + showQr(qr.qrImageBase64); + for (;;) { + await sleep(2000); + const s = await api.get(`/api/v1/auth/qr/${qr.sessionId}`); + repaintQrIfChanged(s.qrImageBase64); + if (s.status === "authenticated") break; + if (s.status === "password_required") { + const pw = await promptPassword(); + await api.post(`/api/v1/auth/qr/${qr.sessionId}/password`, { password: pw }); + } + if (s.status === "cancelled" || s.status === "error") throw new Error(s.error); + } + return (await api.get("/api/v1/auth/me")); +} +``` + +Next: [channels.md](channels.md). diff --git a/docs/api/channels.md b/docs/api/channels.md new file mode 100644 index 0000000..d2fb347 --- /dev/null +++ b/docs/api/channels.md @@ -0,0 +1,237 @@ +# Channels + +A **channel** in this API is any Telegram peer the signed-in account can see: a +broadcast channel, a group, or a one-to-one chat. The channel id (a number like +`1290586824`) is the key you pass everywhere. + +When the app **indexes** a channel it walks its message history and records every +file in a MongoDB database named after the channel id. That index is what the +[files](files.md) endpoints browse β€” fast, paged, searchable β€” without hitting +Telegram again. A channel with an index is called *saved*. + +All endpoints here require a Telegram session ([authentication.md](authentication.md)). + +--- + +## List channels + +``` +GET /api/v1/channels +``` + +| Query | Default | Notes | +| --- | --- | --- | +| `onlySaved` | `false` | Only channels that already have a local index. | +| `favoritesOnly` | `false` | Only favourites. | +| `search` | – | Case-insensitive substring on the name. | +| `sortBy` | `name` | `name` or `id`. | +| `sortDescending` | `false` | | +| `page`, `pageSize` | 1, 50 | | + +```json +{ + "success": true, + "data": [ + { + "id": 1290586824, + "name": "Fresh Electronic Music | EDM", + "type": "channel", + "isOwner": false, + "isFavorite": true, + "imageUrl": "/api/channel/image/1290586824", + "hasDatabase": true + } + ], + "page": { "page": 1, "pageSize": 50, "totalItems": 42, "totalPages": 1, "hasNext": false, "hasPrevious": false } +} +``` + +`type` is `channel`, `group` or `chat`. `imageUrl` is relative; the avatar bytes +are served by `GET /api/v1/channels/{id}/image` (below). + +> **Tip:** For a file-manager UI, start with `?onlySaved=true` β€” those are the +> channels you can actually browse. Offer the full list (`onlySaved=false`) when +> the user wants to index a new channel. + +## Channels grouped by folder + +``` +GET /api/v1/channels/folders +``` + +Mirrors Telegram's chat folders (a.k.a. chat filters): + +```json +{ + "data": { + "folders": [ + { "id": 2, "title": "Music", "iconEmoji": "🎡", "channels": [ … ], "channelCount": 7 } + ], + "ungrouped": [ … ], + "totalChannels": 42 + } +} +``` + +## Favourites + +``` +GET /api/v1/channels/favorites # list (optional ?refresh=false) +POST /api/v1/channels/{id}/favorite # add +DELETE /api/v1/channels/{id}/favorite # remove +``` + +Favourites are stored in the app configuration and shared with the web UI. + +## Channel details + +``` +GET /api/v1/channels/{id} +``` + +Adds indexed-content statistics on top of the basic fields: + +```json +{ + "data": { + "id": 1290586824, + "name": "Fresh Electronic Music | EDM", + "type": "channel", + "isOwner": false, + "isFavorite": true, + "imageUrl": "/api/channel/image/1290586824", + "hasDatabase": true, + "fileCount": 3120, + "folderCount": 12, + "totalSize": 41231234567, + "totalSizeText": "38.4 GB", + "audioCount": 3040, + "videoCount": 5, + "photoCount": 40, + "documentCount": 35, + "isRefreshing": false, + "canRefresh": true + } +} +``` + +`canRefresh` is `false` for channels you own unless +`enableRefreshOwnChannels` is set in the configuration. + +## Create a channel + +``` +POST /api/v1/channels +{ "title": "My Backup", "about": "Personal file storage", "createDatabase": true } +``` + +Creates a Telegram channel owned by the account and, by default, its local +index at the same time so you can immediately use it as an upload target. +Returns `201` with the new channel. + +## The local index (database) + +``` +POST /api/v1/channels/{id}/database # create the index for an existing channel +DELETE /api/v1/channels/{id}/database # drop the index (files stay in Telegram) +``` + +Dropping the index only makes the app forget the folder structure; nothing is +deleted from Telegram. Rebuild it with a refresh. + +## Leave or delete a channel + +``` +POST /api/v1/channels/{id}/leave +{ "deleteLocalDatabase": true, "deleteOnTelegram": false } +``` + +- `deleteOnTelegram: false` (default) β€” just leave the channel. +- `deleteOnTelegram: true` β€” delete the channel for everyone. **Owner only** + (otherwise `403 forbidden`), irreversible, and it destroys the files stored + inside. +- `deleteLocalDatabase: true` β€” also drop the local index. + +## Refresh (index new files) + +``` +POST /api/v1/channels/{id}/refresh +{ + "includeDocuments": true, + "includeAudio": true, + "includeVideo": true, + "includePhotos": true, + "force": false +} +``` + +Scans the channel on Telegram and adds files that are not indexed yet. This is +how a channel becomes *saved* and how new uploads by others become visible. + +- Returns `202 Accepted` immediately; the scan runs in the background and can + take minutes on large channels. +- Only new files are added, so repeated calls are safe (idempotent in effect). +- `409 already_running` if a scan is already in progress. + +Poll the state with: + +``` +GET /api/v1/channels/{id}/refresh -> true | false +``` + +and watch `/hubs/transfers` for the download/index activity it generates. + +## Message history + +``` +GET /api/v1/channels/{id}/messages?limit=30&offset=0&onlyMedia=true +``` + +Reads the raw recent history straight from Telegram (does not use the index), so +it works even for channels that were never indexed. Use it to build a +"messages" view and hand message ids to +`POST /api/v1/transfers/messages` to download their attachments. + +```json +{ + "data": [ + { + "id": 88213, + "date": "2026-07-20T18:04:11Z", + "text": "New release!", + "hasMedia": true, + "mediaType": "audio", + "fileName": "NIVIRO - Flashes.mp3", + "fileSize": 8123456, + "mimeType": "audio/mpeg", + "from": "Alice" + } + ] +} +``` + +`limit` is clamped to 1–100. `mediaType` is `photo`, `video`, `audio`, +`document` or null. + +## Avatar + +``` +GET /api/v1/channels/{id}/image +``` + +Returns the channel avatar as `image/jpeg`, or `404` when there is none. Because +`` cannot send headers, pass the key in the query string: +`/api/v1/channels/{id}/image?apiKey=…`. + +## Invitations + +``` +GET /api/v1/channels/{id}/invitation # get (or generate) the invite link +POST /api/v1/channels/join?hash=… # join using an invite hash +``` + +The `hash` is the part after `t.me/+` or `joinchat/` in an invite link. Joining +is also done automatically when you import a [share](shares.md) whose channel you +are not a member of. + +Next: [files.md](files.md). diff --git a/docs/api/files.md b/docs/api/files.md new file mode 100644 index 0000000..98ddd32 --- /dev/null +++ b/docs/api/files.md @@ -0,0 +1,234 @@ +# Files (channel storage) + +These endpoints browse and manage the files a channel stores in Telegram, using +the local index. They mirror the **Remote** tab of the web file manager. All are +under: + +``` +/api/v1/channels/{channelId}/files +``` + +and require a Telegram session. + +## Addressing folders + +A folder can be addressed two ways, accepted interchangeably (id wins when both +are given): + +- **By path** β€” `?path=/music/rock/`. Human-readable, always ends with `/`, `/` + is the channel root. +- **By id** β€” `?folderId=694a…`. The MongoDB id of the folder document, returned + as `currentFolderId` and on each folder item. + +Files are addressed by their id (`fileId`), returned on every file item. + +## The file object + +Every file/folder is returned as this shape: + +```json +{ + "id": "694a7ec440073c1c7e42f678", + "name": "NIVIRO - Flashes.mp3", + "path": "/music/rock/", + "parentId": "6949…", + "isFile": true, + "hasChildren": false, + "size": 8123456, + "sizeText": "7.7 MB", + "type": ".mp3", + "category": "Audio", + "dateCreated": "2026-05-01T10:00:00Z", + "dateModified": "2026-05-01T10:00:00Z", + "messageId": 88213, + "isSplit": false, + "md5Hash": null, + "xxHash": null, + "streamUrl": "http://host/api/file/GetFileStreamCached/1290586824/694a…/NIVIRO%20-%20Flashes.mp3", + "downloadUrl": "http://host/api/file/GetFileByTfmId/NIVIRO%20-%20Flashes.mp3?idChannel=1290586824&idFile=694a…" +} +``` + +- **`category`** β€” one of `Audio`, `Video`, `Photo`, `Document`, `Archive`, + `Application`, `Other`, `Folder`. +- **`streamUrl`** β€” present for audio/video. Range-capable, so it can be fed + directly to a player. Append `&apiKey=…` when the media element can't send + headers. +- **`downloadUrl`** β€” downloads the whole file, using the server cache when it + already has it. +- **`isSplit`** β€” the file was uploaded as several Telegram messages (files over + Telegram's per-message size limit). The stream/download URLs handle + reassembly transparently. +- **`messageId`** β€” the backing Telegram message (null when split). + +> `streamUrl`/`downloadUrl` are read-through: they pull from Telegram on demand +> and cache to disk. To pull a file onto the server as a managed, resumable +> transfer instead, use [transfers](transfers.md). + +## Browse a folder + +``` +GET /api/v1/channels/{channelId}/files?path=/&filter=audio&sortBy=name&page=1&pageSize=100 +``` + +| Query | Default | Notes | +| --- | --- | --- | +| `path` / `folderId` | root | Folder to list. | +| `filter` | `all` | `audio`, `video`, `photo`, `document`, `archive`, `all`. | +| `search` | – | Substring on the name, within this folder. | +| `filesOnly` | `false` | Hide folders. | +| `sortBy` | `name` | `name`, `date`, `size`, `type`. Folders always sort first. | +| `sortDescending` | `false` | | +| `page`, `pageSize` | 1, 50 | | + +The payload includes the items (paged), navigation, aggregate stats and a +breadcrumb: + +```json +{ + "data": { + "channelId": "1290586824", + "currentPath": "/music/rock/", + "currentFolderId": "6949…", + "parentFolderId": "6948…", + "parentPath": "/music/", + "folderName": "rock", + "items": [ … ], + "stats": { + "folderCount": 3, "fileCount": 120, + "audioCount": 118, "videoCount": 0, "photoCount": 1, "documentCount": 1, + "totalSize": 934512345, "totalSizeText": "891 MB" + }, + "breadcrumbs": [ + { "name": "Files", "path": "/", "folderId": null }, + { "name": "music", "path": "/music/", "folderId": null }, + { "name": "rock", "path": "/music/rock/", "folderId": null } + ] + }, + "page": { "page": 1, "pageSize": 100, "totalItems": 123, "totalPages": 2, "hasNext": true, "hasPrevious": false } +} +``` + +`stats` describes the **whole folder**, not just the current page. + +## Search a subtree + +``` +GET /api/v1/channels/{channelId}/files/search?q=flashes&path=/music/&filter=audio +``` + +Searches file names within `path` (default: the whole channel). Returns a flat, +paged list of file objects. Same `filter`/`sortBy`/paging as browse. + +## One entry + +``` +GET /api/v1/channels/{channelId}/files/{fileId} +``` + +Returns a single file object. `404 file_not_found` when unknown. + +## Folder statistics + +``` +GET /api/v1/channels/{channelId}/files/stats?path=/music/ +``` + +Recursive size and type breakdown of a subtree (an `ApiFolderStatsDto`). Handy +for a "folder properties" screen. + +## Create a folder + +``` +POST /api/v1/channels/{channelId}/files/folders +{ "path": "/music/", "name": "rock" } +``` + +Returns `201` with the created folder. `409 conflict` when a sibling with that +name exists. The name may not contain `/` or `\`. + +## Rename + +``` +PUT /api/v1/channels/{channelId}/files/{fileId}/name +{ "newName": "Best of Rock.mp3" } +``` + +Works for files and folders. Returns the updated entry. + +## Delete + +``` +POST /api/v1/channels/{channelId}/files/delete +{ "ids": ["694a…", "694b…"] } +``` + +Deletes files and folders (folders recursively). **This also deletes the backing +Telegram messages** when no other indexed entry references them, so it actually +frees the channel storage. Irreversible. + +```json +{ "data": { "accepted": 2, "skipped": [], "taskId": null }, "message": "2 entries deleted" } +``` + +`skipped` lists ids that could not be resolved or deleted. + +## Copy / move + +``` +POST /api/v1/channels/{channelId}/files/copy +POST /api/v1/channels/{channelId}/files/move +{ "ids": ["694a…"], "targetPath": "/backup/" } // or "targetFolderId": "…" +``` + +Both operate **within the same channel**. Copies are index-level: the Telegram +messages are shared between the original and the copy, so a copy consumes no +extra channel storage. `409 conflict` on a name clash in the target. + +## Upload a file into the channel + +Two ways to get bytes into a channel: + +### A. Direct multipart upload (client β†’ server β†’ Telegram) + +``` +POST /api/v1/channels/{channelId}/files/upload +Content-Type: multipart/form-data + +file= +path=/incoming/ (form field, optional) +``` + +The server streams the body to Telegram. It appears in the task list, is +persisted and streams progress on the hub, exactly like a web upload. Returns +`202 Accepted`. + +### B. Upload a file already on the server + +If the bytes are already under the server's local root (for example the client +pushed them via `POST /api/v1/local/upload` first, or they were downloaded +earlier), use the transfers endpoint instead so the bytes aren't sent twice: + +``` +POST /api/v1/transfers/uploads +{ "channelId": "…", "localPaths": ["incoming/song.mp3"], "targetPath": "/music/" } +``` + +See [transfers.md](transfers.md). + +## Export / import a channel index + +Move a library between server instances without re-uploading the files. + +``` +GET /api/v1/channels/{channelId}/files/export # -> application/json download +POST /api/v1/channels/{channelId}/files/import # multipart: file= +``` + +The export is a JSON description of the index (names, sizes, Telegram message +ids). Import rebuilds the index on another instance; the files are read from +Telegram, so the importing account must be a member of the channel (see +[shares](shares.md) for the flow that also handles joining). Import runs in the +background and returns `202 Accepted`. + +Next: [transfers.md](transfers.md). diff --git a/docs/api/getting-started.md b/docs/api/getting-started.md new file mode 100644 index 0000000..0b957a3 --- /dev/null +++ b/docs/api/getting-started.md @@ -0,0 +1,189 @@ +# Getting started + +## Base URL + +Every endpoint in this document is relative to the server root. If the app runs +on `http://192.168.1.50:5257`, then `/api/v1/system/ping` is +`http://192.168.1.50:5257/api/v1/system/ping`. + +The app also listens on HTTPS (port `7224` by default). Behind a reverse proxy +(nginx, Traefik…) the app honours `X-Forwarded-Proto`/`X-Forwarded-Host`, so the +absolute `streamUrl`/`downloadUrl` values it builds use the public scheme and +host the client actually reached. + +## Authentication: the API key + +Two independent layers guard the API: + +1. **API key** β€” a static shared secret that gates *every* `/api/v1`, `/api/mobile` + and `/hubs` request. It answers "is this client allowed to talk to the server + at all?". +2. **Telegram session** β€” the signed-in Telegram account. It answers "is there + an account whose channels and files we can act on?". See + [authentication.md](authentication.md). + +### Configuring the API key + +The key is `mobile_api_key` in `Configuration/config.json` (or the +`mobile_api_key` environment variable / setup wizard): + +```json +{ + "api_id": "…", + "hash_id": "…", + "mongo_connection_string": "…", + "mobile_api_key": "choose-a-long-random-secret" +} +``` + +- **If the key is empty or missing, authentication is disabled** and every + request is allowed. This is convenient for local development but must not be + used on a reachable network. `GET /api/v1/system/info` reports + `requiresApiKey` so a client can tell. +- The key is compared with an ordinal (exact, case-sensitive) match. + +### Sending the API key + +| Transport | How | +| --- | --- | +| HTTP header (preferred) | `X-Api-Key: your-secret` | +| Query string | `?apiKey=your-secret` | +| SignalR / media `