diff --git a/OtthonApi/Program.cs b/OtthonApi/Program.cs index b6a0dfb..c366769 100644 --- a/OtthonApi/Program.cs +++ b/OtthonApi/Program.cs @@ -1,9 +1,12 @@ using System.Net.Http.Headers; using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -151,6 +154,214 @@ app.MapGet("/api/app-config", async (HttpContext context, OtthonDbContext db) => !string.IsNullOrWhiteSpace(googleClientId))); }); +app.MapGet("/api/google/oauth/start", async ( + HttpContext context, + OtthonDbContext db) => +{ + if (!HasApiToken(context)) + { + return Results.Unauthorized(); + } + + var workspaceToken = GetWorkspaceToken(context); + if (string.IsNullOrWhiteSpace(workspaceToken)) + { + return Results.BadRequest(new { message = "Missing X-Workspace-Token header." }); + } + + var clientId = await GetGoogleClientId(db); + var clientSecret = await GetGoogleClientSecret(db); + if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) + { + return Results.BadRequest(new { message = "Google OAuth client is not configured." }); + } + + var stateValue = RandomToken(32); + db.GoogleOAuthStates.Add(new GoogleOAuthState + { + State = stateValue, + WorkspaceToken = workspaceToken, + CreatedUtc = DateTimeOffset.UtcNow, + ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(15) + }); + await db.SaveChangesAsync(); + + var redirectUri = GoogleOAuthRedirectUri(); + var authUri = QueryHelpers.AddQueryString("https://accounts.google.com/o/oauth2/v2/auth", new Dictionary + { + ["client_id"] = clientId, + ["redirect_uri"] = redirectUri, + ["response_type"] = "code", + ["scope"] = "openid email https://www.googleapis.com/auth/calendar.readonly", + ["access_type"] = "offline", + ["prompt"] = "consent", + ["state"] = stateValue + }); + + return Results.Ok(new GoogleOAuthStartResponse(authUri)); +}); + +app.MapGet("/api/google/oauth/callback", async ( + string? code, + string? state, + string? error, + OtthonDbContext db, + IHttpClientFactory httpClientFactory) => +{ + if (!string.IsNullOrWhiteSpace(error)) + { + return Results.Content(GoogleOAuthResultPage(false, $"Google rejected the request: {Html(error)}"), "text/html; charset=utf-8"); + } + + if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state)) + { + return Results.Content(GoogleOAuthResultPage(false, "Missing OAuth code or state."), "text/html; charset=utf-8"); + } + + var savedState = await db.GoogleOAuthStates.FindAsync(state); + if (savedState is null || savedState.ExpiresUtc < DateTimeOffset.UtcNow) + { + return Results.Content(GoogleOAuthResultPage(false, "OAuth session expired. Start Google connection again from the tablet."), "text/html; charset=utf-8"); + } + + var clientId = await GetGoogleClientId(db); + var clientSecret = await GetGoogleClientSecret(db); + if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) + { + return Results.Content(GoogleOAuthResultPage(false, "Google OAuth client is not configured in JARVIS."), "text/html; charset=utf-8"); + } + + var http = httpClientFactory.CreateClient(); + var tokenResponse = await http.PostAsync( + "https://oauth2.googleapis.com/token", + new FormUrlEncodedContent(new Dictionary + { + ["code"] = code, + ["client_id"] = clientId, + ["client_secret"] = clientSecret, + ["redirect_uri"] = GoogleOAuthRedirectUri(), + ["grant_type"] = "authorization_code" + })); + var tokenBody = await tokenResponse.Content.ReadAsStringAsync(); + if (!tokenResponse.IsSuccessStatusCode) + { + return Results.Content(GoogleOAuthResultPage(false, $"Token exchange failed: {Html(tokenBody)}"), "text/html; charset=utf-8"); + } + + using var tokenJson = JsonDocument.Parse(tokenBody); + var accessToken = GetString(tokenJson.RootElement, "access_token"); + var refreshToken = GetString(tokenJson.RootElement, "refresh_token"); + var expiresIn = tokenJson.RootElement.TryGetProperty("expires_in", out var expiresElement) + ? expiresElement.GetInt32() + : 3600; + + if (string.IsNullOrWhiteSpace(accessToken)) + { + return Results.Content(GoogleOAuthResultPage(false, "Google did not return an access token."), "text/html; charset=utf-8"); + } + + var email = await LoadGoogleEmail(http, accessToken); + var auth = await db.GoogleCalendarAuths.FindAsync(savedState.WorkspaceToken); + if (auth is null) + { + auth = new GoogleCalendarAuth + { + WorkspaceToken = savedState.WorkspaceToken, + AccessToken = accessToken, + RefreshToken = refreshToken ?? "", + Email = email, + ExpiresUtc = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, expiresIn - 60)), + UpdatedUtc = DateTimeOffset.UtcNow + }; + db.GoogleCalendarAuths.Add(auth); + } + else + { + auth.AccessToken = accessToken; + if (!string.IsNullOrWhiteSpace(refreshToken)) + { + auth.RefreshToken = refreshToken; + } + auth.Email = email ?? auth.Email; + auth.ExpiresUtc = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, expiresIn - 60)); + auth.UpdatedUtc = DateTimeOffset.UtcNow; + } + + db.GoogleOAuthStates.Remove(savedState); + await db.SaveChangesAsync(); + return Results.Content(GoogleOAuthResultPage(true, $"Google Calendar connected for {Html(auth.Email ?? "the selected account")}."), "text/html; charset=utf-8"); +}); + +app.MapGet("/api/google/oauth/status", async (HttpContext context, OtthonDbContext db) => +{ + if (!HasApiToken(context)) return Results.Unauthorized(); + var workspaceToken = GetWorkspaceToken(context); + if (string.IsNullOrWhiteSpace(workspaceToken)) + { + return Results.BadRequest(new { message = "Missing X-Workspace-Token header." }); + } + var auth = await db.GoogleCalendarAuths.FindAsync(workspaceToken); + return Results.Ok(new GoogleOAuthStatusResponse(auth is not null, auth?.Email, auth?.UpdatedUtc)); +}); + +app.MapGet("/api/google/calendars", async ( + HttpContext context, + OtthonDbContext db, + IHttpClientFactory httpClientFactory) => +{ + if (!HasApiToken(context)) return Results.Unauthorized(); + var workspaceToken = GetWorkspaceToken(context); + if (string.IsNullOrWhiteSpace(workspaceToken)) + { + return Results.BadRequest(new { message = "Missing X-Workspace-Token header." }); + } + var token = await GetGoogleAccessToken(db, httpClientFactory, workspaceToken); + if (token is null) return Results.BadRequest(new { message = "Google Calendar is not connected." }); + + var http = httpClientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, "https://www.googleapis.com/calendar/v3/users/me/calendarList?minAccessRole=reader&showHidden=true"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + var response = await http.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + return Results.Content(body, "application/json; charset=utf-8", statusCode: (int)response.StatusCode); +}); + +app.MapGet("/api/google/events", async ( + HttpContext context, + string calendarId, + string? timeMin, + string? timeMax, + OtthonDbContext db, + IHttpClientFactory httpClientFactory) => +{ + if (!HasApiToken(context)) return Results.Unauthorized(); + var workspaceToken = GetWorkspaceToken(context); + if (string.IsNullOrWhiteSpace(workspaceToken)) + { + return Results.BadRequest(new { message = "Missing X-Workspace-Token header." }); + } + var token = await GetGoogleAccessToken(db, httpClientFactory, workspaceToken); + if (token is null) return Results.BadRequest(new { message = "Google Calendar is not connected." }); + + var query = new Dictionary + { + ["singleEvents"] = "true", + ["orderBy"] = "startTime", + ["timeMin"] = timeMin, + ["timeMax"] = timeMax, + ["maxResults"] = "2500" + }; + var uri = QueryHelpers.AddQueryString( + $"https://www.googleapis.com/calendar/v3/calendars/{Uri.EscapeDataString(calendarId)}/events", + query); + var http = httpClientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + var response = await http.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + return Results.Content(body, "application/json; charset=utf-8", statusCode: (int)response.StatusCode); +}); + app.MapPost("/api/devices/register", async ( DeviceRegistrationRequest request, HttpContext context, @@ -661,6 +872,88 @@ static async Task SetSetting(OtthonDbContext db, string key, string value) await db.SaveChangesAsync(); } +static string RandomToken(int byteCount) +{ + var bytes = RandomNumberGenerator.GetBytes(byteCount); + return Convert.ToBase64String(bytes) + .Replace("+", "-") + .Replace("/", "_") + .TrimEnd('='); +} + +static string GoogleOAuthRedirectUri() => + "https://api-test.a-web.hu/api/google/oauth/callback"; + +static string GoogleCalendarServerClientIdSettingKey() => "GoogleCalendarServerClientId"; +static string GoogleCalendarClientSecretSettingKey() => "GoogleCalendarClientSecret"; + +static async Task GetGoogleClientId(OtthonDbContext db) => + await GetSetting(db, GoogleCalendarServerClientIdSettingKey()); + +static async Task GetGoogleClientSecret(OtthonDbContext db) => + await GetSetting(db, GoogleCalendarClientSecretSettingKey()); + +static async Task LoadGoogleEmail(HttpClient http, string accessToken) +{ + try + { + var request = new HttpRequestMessage(HttpMethod.Get, "https://www.googleapis.com/oauth2/v2/userinfo"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + var response = await http.SendAsync(request); + if (!response.IsSuccessStatusCode) return null; + var body = await response.Content.ReadAsStringAsync(); + using var json = JsonDocument.Parse(body); + return GetString(json.RootElement, "email"); + } + catch + { + return null; + } +} + +static async Task GetGoogleAccessToken( + OtthonDbContext db, + IHttpClientFactory httpClientFactory, + string workspaceToken) +{ + var auth = await db.GoogleCalendarAuths.FindAsync(workspaceToken); + if (auth is null) return null; + if (auth.ExpiresUtc > DateTimeOffset.UtcNow.AddMinutes(2)) + { + return auth.AccessToken; + } + if (string.IsNullOrWhiteSpace(auth.RefreshToken)) return null; + + var clientId = await GetGoogleClientId(db); + var clientSecret = await GetGoogleClientSecret(db); + if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) return null; + + var http = httpClientFactory.CreateClient(); + var response = await http.PostAsync( + "https://oauth2.googleapis.com/token", + new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = clientId, + ["client_secret"] = clientSecret, + ["refresh_token"] = auth.RefreshToken, + ["grant_type"] = "refresh_token" + })); + var body = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) return null; + + using var json = JsonDocument.Parse(body); + var accessToken = GetString(json.RootElement, "access_token"); + if (string.IsNullOrWhiteSpace(accessToken)) return null; + var expiresIn = json.RootElement.TryGetProperty("expires_in", out var expiresElement) + ? expiresElement.GetInt32() + : 3600; + auth.AccessToken = accessToken; + auth.ExpiresUtc = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, expiresIn - 60)); + auth.UpdatedUtc = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + return auth.AccessToken; +} + static void ApplyProductLookup(Product target, Product lookup, string source) { target.Name = lookup.Name; @@ -747,6 +1040,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger) await EnsureFoodLearningSchemaAsync(db); await EnsureWorkspaceSchemaAsync(db); await EnsureAppSettingsSchemaAsync(db); + await EnsureGoogleCalendarSchemaAsync(db); return; } catch (Exception ex) when (attempt < maxAttempts) @@ -765,6 +1059,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger) await EnsureFoodLearningSchemaAsync(db); await EnsureWorkspaceSchemaAsync(db); await EnsureAppSettingsSchemaAsync(db); + await EnsureGoogleCalendarSchemaAsync(db); } static async Task EnsureProductSchemaAsync(OtthonDbContext db) @@ -815,6 +1110,33 @@ END await db.Database.ExecuteSqlRawAsync(sql); } +static async Task EnsureGoogleCalendarSchemaAsync(OtthonDbContext db) +{ + var sql = """ +IF OBJECT_ID('GoogleCalendarAuths', 'U') IS NULL +BEGIN + CREATE TABLE GoogleCalendarAuths ( + WorkspaceToken nvarchar(160) NOT NULL CONSTRAINT PK_GoogleCalendarAuths PRIMARY KEY, + Email nvarchar(320) NULL, + AccessToken nvarchar(max) NOT NULL, + RefreshToken nvarchar(max) NOT NULL, + ExpiresUtc datetimeoffset NOT NULL, + UpdatedUtc datetimeoffset NOT NULL + ); +END +IF OBJECT_ID('GoogleOAuthStates', 'U') IS NULL +BEGIN + CREATE TABLE GoogleOAuthStates ( + [State] nvarchar(160) NOT NULL CONSTRAINT PK_GoogleOAuthStates PRIMARY KEY, + WorkspaceToken nvarchar(160) NOT NULL, + CreatedUtc datetimeoffset NOT NULL, + ExpiresUtc datetimeoffset NOT NULL + ); +END +"""; + await db.Database.ExecuteSqlRawAsync(sql); +} + static async Task EnsureFoodLearningSchemaAsync(OtthonDbContext db) { var sql = """ @@ -1047,9 +1369,27 @@ static string SettingsPage(string googleClientId) => $$""" """; -static string Html(string? value) => System.Net.WebUtility.HtmlEncode(value ?? ""); +static string GoogleOAuthResultPage(bool success, string message) => $$""" + + + + + + Google Calendar connection + + + +
+

JARVIS API

+

{{(success ? "Google Calendar connected" : "Google Calendar error")}}

+

{{message}}

+

You can close this browser window and return to Közös Tér.

+
+ + +"""; -static string GoogleCalendarServerClientIdSettingKey() => "GoogleCalendarServerClientId"; +static string Html(string? value) => System.Net.WebUtility.HtmlEncode(value ?? ""); static string AdminCss() => """ :root { color-scheme: light; font-family: Inter, Segoe UI, Arial, sans-serif; } @@ -1091,11 +1431,19 @@ public sealed class OtthonDbContext(DbContextOptions options) public DbSet FoodLearningEvents => Set(); public DbSet Workspaces => Set(); public DbSet AppSettings => Set(); + public DbSet GoogleCalendarAuths => Set(); + public DbSet GoogleOAuthStates => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity().HasKey(setting => setting.Key); modelBuilder.Entity().Property(setting => setting.Key).HasMaxLength(160); + modelBuilder.Entity().HasKey(auth => auth.WorkspaceToken); + modelBuilder.Entity().Property(auth => auth.WorkspaceToken).HasMaxLength(160); + modelBuilder.Entity().Property(auth => auth.Email).HasMaxLength(320); + modelBuilder.Entity().HasKey(state => state.State); + modelBuilder.Entity().Property(state => state.State).HasMaxLength(160); + modelBuilder.Entity().Property(state => state.WorkspaceToken).HasMaxLength(160); modelBuilder.Entity().HasKey(workspace => workspace.Token); modelBuilder.Entity().Property(workspace => workspace.Token).HasMaxLength(160); modelBuilder.Entity().HasKey(product => product.Barcode); @@ -1177,6 +1525,24 @@ public sealed class AppSetting public DateTimeOffset UpdatedUtc { get; set; } } +public sealed class GoogleCalendarAuth +{ + public required string WorkspaceToken { get; set; } + public string? Email { get; set; } + public required string AccessToken { get; set; } + public required string RefreshToken { get; set; } + public DateTimeOffset ExpiresUtc { get; set; } + public DateTimeOffset UpdatedUtc { get; set; } +} + +public sealed class GoogleOAuthState +{ + public required string State { get; set; } + public required string WorkspaceToken { get; set; } + public DateTimeOffset CreatedUtc { get; set; } + public DateTimeOffset ExpiresUtc { get; set; } +} + public sealed record LoginForm(string Password); public sealed record GoogleCalendarSettingsForm(string? ServerClientId); public sealed record DeviceRegistrationRequest(string? DeviceName, string? Platform); @@ -1252,6 +1618,13 @@ public sealed record AppConfigResponse( string? GoogleCalendarServerClientId, bool GoogleCalendarConfigured); +public sealed record GoogleOAuthStartResponse(string AuthorizationUrl); + +public sealed record GoogleOAuthStatusResponse( + bool Connected, + string? Email, + DateTimeOffset? UpdatedUtc); + public sealed record ProductResponse( bool Found, string Barcode,