Skip to content

Commit 4e0eec0

Browse files
authored
feat(api): add modular v1 REST + SignalR API and make mobile app builds on-demand (#116)
Modular API v1 (/api/v1) exposing the full web feature set for a future mobile app, plus live transfer progress over SignalR, plus complete English docs. API - Controllers under Controllers/Api/V1: Auth (phone + QR login), Channels, Files, Transfers, LocalFiles, Playlists, Shares, Config, System. - Consistent envelope {success,data,error,message,page} with stable error codes. - SignalR hub /hubs/transfers (TransferHub) bridged from TransactionInfoService by a hosted TransferBroadcastService (snapshot/summary/speed messages). - DTOs in Models/Api; helpers in Services/Api (QrLoginSessionManager, ChannelFolderResolver, TransferSnapshotBuilder, upload staging). - API-key middleware now covers /api/v1 and /hubs, accepting X-Api-Key, ?apiKey=, ?access_token= (WebSocket) and Authorization: Bearer (negotiate). - Second Swagger document "api-v1" at /swagger/api-v1/swagger.json (/api-docs), built from XML doc comments (GenerateDocumentationFile enabled). - Verified at runtime: OpenAPI generation, config clamping, full SignalR handshake with snapshot-on-connect. Release policy - buildrelease.yml: a plain v* release now builds only the Server. The mobile / desktop apps (Android/Windows/macOS) build on demand only, via an app-v* release tag or the manual workflow_dispatch checkboxes. - Documented in docs/releases.md. Docs - docs/api/* (getting-started, authentication, channels, files, transfers, signalr, local-files, playlists, shares, system-and-config, reference).
1 parent faaa062 commit 4e0eec0

40 files changed

Lines changed: 7698 additions & 33 deletions

.github/workflows/buildrelease.yml

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ name: Build and Release
33
# Triggers:
44
# - Release published with tags:
55
# - server-v* : Build only Server
6-
# - app-v* : Build only TFMAudioApp (Android, Windows, macOS)
7-
# - v* : Build everything
8-
# - Manual workflow dispatch with checkboxes
6+
# - app-v* : Build only TFMAudioApp (Android, Windows, macOS) — on demand
7+
# - v* : Build only Server (mobile apps are NOT built by default)
8+
# - Manual workflow dispatch with checkboxes (build any target on demand)
9+
#
10+
# NOTE: Mobile apps (Android/Windows/macOS) are intentionally NOT built for a
11+
# plain "v*" release. Build them on demand when they actually change, either
12+
# with an "app-v*" release tag or via the manual workflow_dispatch checkboxes.
13+
# See docs/releases.md for the full policy.
914

1015
on:
1116
release:
@@ -128,10 +133,11 @@ jobs:
128133
build-android:
129134
name: Build Android APK
130135
runs-on: ubuntu-latest
131-
# Run if: manual with build_android OR release with app-v* or v* (but not server-v*)
136+
# On demand only: manual with build_android OR release with an app-v* tag.
137+
# A plain v* release does NOT build the mobile apps.
132138
if: |
133139
(github.event_name == 'workflow_dispatch' && inputs.build_android) ||
134-
(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'))))
140+
(github.event_name == 'release' && startsWith(github.event.release.tag_name, 'app-v'))
135141
steps:
136142
- name: '📄 Checkout'
137143
uses: actions/checkout@v4
@@ -317,10 +323,11 @@ jobs:
317323
build-windows:
318324
name: Build Windows App
319325
runs-on: windows-2022
320-
# Run if: manual with build_windows OR release with app-v* or v* (but not server-v*)
326+
# On demand only: manual with build_windows OR release with an app-v* tag.
327+
# A plain v* release does NOT build the mobile apps.
321328
if: |
322329
(github.event_name == 'workflow_dispatch' && inputs.build_windows) ||
323-
(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'))))
330+
(github.event_name == 'release' && startsWith(github.event.release.tag_name, 'app-v'))
324331
steps:
325332
- name: '📄 Checkout'
326333
uses: actions/checkout@v4
@@ -449,10 +456,11 @@ jobs:
449456
build-macos:
450457
name: Build macOS App
451458
runs-on: macos-15
452-
# Run if: manual with build_macos OR release with app-v* or v* (but not server-v*)
459+
# On demand only: manual with build_macos OR release with an app-v* tag.
460+
# A plain v* release does NOT build the mobile apps.
453461
if: |
454462
(github.event_name == 'workflow_dispatch' && inputs.build_macos) ||
455-
(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'))))
463+
(github.event_name == 'release' && startsWith(github.event.release.tag_name, 'app-v'))
456464
steps:
457465
- name: '📄 Checkout'
458466
uses: actions/checkout@v4
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using TelegramDownloader.Models.Api;
3+
4+
namespace TelegramDownloader.Controllers.Api.V1
5+
{
6+
/// <summary>
7+
/// Shared plumbing for every v1 controller: consistent envelopes, consistent
8+
/// status codes and a helper to build absolute URLs behind a reverse proxy.
9+
/// </summary>
10+
[ApiController]
11+
[Produces("application/json")]
12+
[ProducesResponseType(typeof(ApiResult<object>), StatusCodes.Status401Unauthorized)]
13+
[ProducesResponseType(typeof(ApiResult<object>), StatusCodes.Status500InternalServerError)]
14+
public abstract class ApiV1ControllerBase : ControllerBase
15+
{
16+
/// <summary>
17+
/// Absolute base URL of this server as seen by the client, honouring
18+
/// <c>X-Forwarded-Proto</c>/<c>X-Forwarded-Host</c> (the app enables
19+
/// forwarded headers at startup).
20+
/// </summary>
21+
protected string BaseUrl => $"{Request.Scheme}://{Request.Host}";
22+
23+
protected IActionResult OkResult<T>(T data, string? message = null) =>
24+
Ok(ApiResult<T>.Ok(data, message));
25+
26+
protected IActionResult OkPaged<T>(T data, PageInfo page) =>
27+
Ok(ApiResult<T>.Ok(data, page));
28+
29+
protected IActionResult OkEmpty(string? message = null) =>
30+
Ok(ApiResult.Done(message));
31+
32+
protected IActionResult BadRequestResult(string message, string code = ApiErrorCodes.InvalidRequest, string? detail = null) =>
33+
BadRequest(ApiResult.Fail(code, message, detail));
34+
35+
protected IActionResult NotFoundResult(string message, string code = ApiErrorCodes.NotFound) =>
36+
NotFound(ApiResult.Fail(code, message));
37+
38+
protected IActionResult ConflictResult(string message, string code = ApiErrorCodes.Conflict) =>
39+
Conflict(ApiResult.Fail(code, message));
40+
41+
protected IActionResult ForbiddenResult(string message) =>
42+
StatusCode(StatusCodes.Status403Forbidden, ApiResult.Fail(ApiErrorCodes.Forbidden, message));
43+
44+
protected IActionResult ErrorResult(string message, Exception? ex = null, string code = ApiErrorCodes.InternalError) =>
45+
StatusCode(StatusCodes.Status500InternalServerError, ApiResult.Fail(code, message, ex?.Message));
46+
47+
protected IActionResult UnavailableResult(string message, string code = ApiErrorCodes.ServiceUnavailable) =>
48+
StatusCode(StatusCodes.Status503ServiceUnavailable, ApiResult.Fail(code, message));
49+
50+
/// <summary>
51+
/// Applies in-memory paging to an already materialised list and returns
52+
/// both the page and its metadata.
53+
/// </summary>
54+
protected static (List<T> Items, PageInfo Page) Paginate<T>(IReadOnlyList<T> source, PagedQuery query)
55+
{
56+
var page = PageInfo.Create(query.Page, query.PageSize, source.Count);
57+
var items = source.Skip((query.Page - 1) * query.PageSize).Take(query.PageSize).ToList();
58+
return (items, page);
59+
}
60+
}
61+
}
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using TelegramDownloader.Data;
3+
using TelegramDownloader.Models.Api;
4+
using TelegramDownloader.Services;
5+
using TelegramDownloader.Services.Api;
6+
7+
namespace TelegramDownloader.Controllers.Api.V1
8+
{
9+
/// <summary>
10+
/// Telegram session lifecycle: sign in with a phone number or a QR code,
11+
/// inspect the current session and sign out.
12+
///
13+
/// The Telegram session lives on the server and is shared by the web UI and
14+
/// every API client: signing in here also signs in the web UI, and signing
15+
/// out terminates both.
16+
/// </summary>
17+
[Route("api/v1/auth")]
18+
[Tags("Auth")]
19+
public class AuthController : ApiV1ControllerBase
20+
{
21+
private readonly ITelegramService _telegram;
22+
private readonly ISetupService _setup;
23+
private readonly QrLoginSessionManager _qr;
24+
private readonly ILogger<AuthController> _logger;
25+
26+
public AuthController(
27+
ITelegramService telegram,
28+
ISetupService setup,
29+
QrLoginSessionManager qr,
30+
ILogger<AuthController> logger)
31+
{
32+
_telegram = telegram;
33+
_setup = setup;
34+
_qr = qr;
35+
_logger = logger;
36+
}
37+
38+
/// <summary>Current authentication state.</summary>
39+
/// <remarks>
40+
/// Call this first. <c>Step</c> tells you what the server expects next:
41+
/// <c>phone</c>, <c>vc</c> (verification code), <c>pass</c> (2FA
42+
/// password), <c>ok</c> (already signed in) or <c>setup_required</c>
43+
/// when the application has not been configured yet.
44+
/// </remarks>
45+
[HttpGet("status")]
46+
[ProducesResponseType(typeof(ApiResult<AuthStatusDto>), StatusCodes.Status200OK)]
47+
public async Task<IActionResult> Status()
48+
{
49+
try
50+
{
51+
var dto = new AuthStatusDto { IsConfigured = _telegram.IsConfigured };
52+
53+
if (!_telegram.IsConfigured)
54+
{
55+
try
56+
{
57+
_telegram.InitializeClient();
58+
dto.IsConfigured = _telegram.IsConfigured;
59+
}
60+
catch (Exception ex)
61+
{
62+
_logger.LogDebug(ex, "Telegram client could not be initialized");
63+
}
64+
}
65+
66+
if (!dto.IsConfigured)
67+
{
68+
dto.Step = AuthStep.SetupRequired;
69+
return OkResult(dto);
70+
}
71+
72+
dto.Step = await _telegram.checkAuth(null) ?? AuthStep.Phone;
73+
dto.IsAuthenticated = dto.Step == AuthStep.Authenticated;
74+
75+
if (dto.IsAuthenticated)
76+
dto.User = await BuildUserAsync();
77+
78+
return OkResult(dto);
79+
}
80+
catch (Exception ex)
81+
{
82+
_logger.LogError(ex, "Error reading auth status");
83+
return ErrorResult("Could not read the authentication status", ex);
84+
}
85+
}
86+
87+
/// <summary>Signed-in Telegram user.</summary>
88+
[HttpGet("me")]
89+
[ProducesResponseType(typeof(ApiResult<TelegramUserDto>), StatusCodes.Status200OK)]
90+
[ProducesResponseType(typeof(ApiResult<object>), StatusCodes.Status401Unauthorized)]
91+
public async Task<IActionResult> Me()
92+
{
93+
if (!_telegram.IsConfigured || !_telegram.checkUserLogin())
94+
return StatusCode(StatusCodes.Status401Unauthorized,
95+
ApiResult.Fail(ApiErrorCodes.NotLoggedIn, "No Telegram session is active"));
96+
97+
var user = await BuildUserAsync();
98+
if (user == null)
99+
return NotFoundResult("The Telegram user could not be resolved");
100+
101+
return OkResult(user);
102+
}
103+
104+
/// <summary>Advances the phone login flow one step.</summary>
105+
/// <remarks>
106+
/// Post the phone number with <c>isPhone: true</c> to start. The response
107+
/// tells you the next step; post the verification code (and then, when
108+
/// required, the two-factor password) with <c>isPhone: false</c>.
109+
///
110+
/// Sample sequence:
111+
/// <code>
112+
/// POST /api/v1/auth/login { "value": "+34600000000", "isPhone": true } -> step "vc"
113+
/// POST /api/v1/auth/login { "value": "12345" } -> step "pass" or "ok"
114+
/// POST /api/v1/auth/login { "value": "my-2fa-password" } -> step "ok"
115+
/// </code>
116+
/// </remarks>
117+
[HttpPost("login")]
118+
[ProducesResponseType(typeof(ApiResult<AuthStatusDto>), StatusCodes.Status200OK)]
119+
[ProducesResponseType(typeof(ApiResult<object>), StatusCodes.Status400BadRequest)]
120+
public async Task<IActionResult> Login([FromBody] LoginStepRequest request)
121+
{
122+
if (request == null || string.IsNullOrWhiteSpace(request.Value))
123+
return BadRequestResult("A value is required for the current login step");
124+
125+
try
126+
{
127+
if (!_telegram.IsConfigured)
128+
_telegram.InitializeClient();
129+
130+
var step = await _telegram.checkAuth(request.Value.Trim(), request.IsPhone) ?? AuthStep.Phone;
131+
132+
var dto = new AuthStatusDto
133+
{
134+
Step = step,
135+
IsConfigured = _telegram.IsConfigured,
136+
IsAuthenticated = step == AuthStep.Authenticated
137+
};
138+
if (dto.IsAuthenticated)
139+
dto.User = await BuildUserAsync();
140+
141+
return OkResult(dto);
142+
}
143+
catch (Exception ex)
144+
{
145+
_logger.LogWarning(ex, "Login step failed");
146+
return BadRequestResult("The login step was rejected by Telegram", ApiErrorCodes.InvalidRequest, ex.Message);
147+
}
148+
}
149+
150+
/// <summary>Starts a QR login session.</summary>
151+
/// <remarks>
152+
/// Render <c>qrImageBase64</c> (a PNG) or encode <c>loginUrl</c> yourself,
153+
/// then poll <c>GET /api/v1/auth/qr/{sessionId}</c>. Telegram rotates the
154+
/// token every ~30 seconds, so keep repainting the QR from the polled
155+
/// value. When the status turns <c>password_required</c>, post the 2FA
156+
/// password to <c>/api/v1/auth/qr/{sessionId}/password</c>.
157+
/// </remarks>
158+
/// <param name="logoutFirst">Terminate any existing session before starting.</param>
159+
[HttpPost("qr")]
160+
[ProducesResponseType(typeof(ApiResult<QrLoginDto>), StatusCodes.Status200OK)]
161+
public async Task<IActionResult> StartQr([FromQuery] bool logoutFirst = false)
162+
{
163+
try
164+
{
165+
if (!_telegram.IsConfigured)
166+
_telegram.InitializeClient();
167+
168+
var session = await _qr.StartAsync(_telegram, logoutFirst);
169+
return OkResult(session);
170+
}
171+
catch (Exception ex)
172+
{
173+
_logger.LogError(ex, "Could not start a QR login session");
174+
return ErrorResult("Could not start a QR login session", ex);
175+
}
176+
}
177+
178+
/// <summary>Polls the state of a QR login session.</summary>
179+
[HttpGet("qr/{sessionId}")]
180+
[ProducesResponseType(typeof(ApiResult<QrLoginDto>), StatusCodes.Status200OK)]
181+
[ProducesResponseType(typeof(ApiResult<object>), StatusCodes.Status404NotFound)]
182+
public IActionResult PollQr(string sessionId)
183+
{
184+
var session = _qr.Get(sessionId);
185+
if (session == null)
186+
return NotFoundResult("Unknown or expired QR login session");
187+
return OkResult(session);
188+
}
189+
190+
/// <summary>Supplies the two-factor password a QR session is waiting for.</summary>
191+
[HttpPost("qr/{sessionId}/password")]
192+
[ProducesResponseType(typeof(ApiResult<QrLoginDto>), StatusCodes.Status200OK)]
193+
[ProducesResponseType(typeof(ApiResult<object>), StatusCodes.Status404NotFound)]
194+
public IActionResult ProvideQrPassword(string sessionId, [FromBody] QrPasswordRequest request)
195+
{
196+
if (request == null || string.IsNullOrEmpty(request.Password))
197+
return BadRequestResult("A password is required");
198+
199+
if (!_qr.ProvidePassword(sessionId, _telegram, request.Password))
200+
return NotFoundResult("Unknown or expired QR login session");
201+
202+
return OkResult(_qr.Get(sessionId)!);
203+
}
204+
205+
/// <summary>Cancels a pending QR login session.</summary>
206+
[HttpDelete("qr/{sessionId}")]
207+
[ProducesResponseType(typeof(ApiResult<object>), StatusCodes.Status200OK)]
208+
[ProducesResponseType(typeof(ApiResult<object>), StatusCodes.Status404NotFound)]
209+
public IActionResult CancelQr(string sessionId)
210+
{
211+
if (!_qr.Cancel(sessionId))
212+
return NotFoundResult("Unknown or expired QR login session");
213+
return OkEmpty("QR login session cancelled");
214+
}
215+
216+
/// <summary>Signs out of Telegram.</summary>
217+
/// <remarks>
218+
/// This terminates the shared server session: the web UI is signed out
219+
/// too and every client has to authenticate again.
220+
/// </remarks>
221+
[HttpPost("logout")]
222+
[ProducesResponseType(typeof(ApiResult<object>), StatusCodes.Status200OK)]
223+
public async Task<IActionResult> Logout()
224+
{
225+
try
226+
{
227+
await _telegram.logOff();
228+
return OkEmpty("Signed out");
229+
}
230+
catch (Exception ex)
231+
{
232+
_logger.LogError(ex, "Error signing out");
233+
return ErrorResult("Could not sign out", ex);
234+
}
235+
}
236+
237+
private async Task<TelegramUserDto?> BuildUserAsync()
238+
{
239+
try
240+
{
241+
var user = await _telegram.GetUser();
242+
if (user == null) return null;
243+
return new TelegramUserDto
244+
{
245+
Id = user.id,
246+
Username = user.username,
247+
FirstName = user.first_name,
248+
LastName = user.last_name,
249+
Phone = user.phone,
250+
IsPremium = TelegramService.isPremium
251+
};
252+
}
253+
catch (Exception ex)
254+
{
255+
_logger.LogDebug(ex, "Could not resolve the Telegram user");
256+
return null;
257+
}
258+
}
259+
}
260+
}

0 commit comments

Comments
 (0)