From 99ec0313dcff94ba27cb0587345da84a02d7244a Mon Sep 17 00:00:00 2001 From: JW-CH <17313367+JW-CH@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:14:32 +0000 Subject: [PATCH 1/5] feat: calendar basic auth via standard URL userinfo format Use standard https://user:pass@host/path format instead of semicolon- delimited auth. Adds error handling for malformed URLs and properly unescapes percent-encoded credentials. --- .../Services/IcalCalendarService.cs | 67 ++++++++++++++----- docker/example.env | 2 +- docs/docs/getting-started/configuration.md | 12 +++- 3 files changed, 62 insertions(+), 19 deletions(-) diff --git a/ImmichFrame.Core/Services/IcalCalendarService.cs b/ImmichFrame.Core/Services/IcalCalendarService.cs index 27ae77eb..6599bdfd 100644 --- a/ImmichFrame.Core/Services/IcalCalendarService.cs +++ b/ImmichFrame.Core/Services/IcalCalendarService.cs @@ -1,16 +1,23 @@ +using System.Net.Http.Headers; +using System.Text; using Ical.Net; using ImmichFrame.Core.Helpers; using ImmichFrame.Core.Interfaces; using ImmichFrame.WebApi.Helpers; +using Microsoft.Extensions.Logging; public class IcalCalendarService : ICalendarService { private readonly IGeneralSettings _serverSettings; - private readonly IApiCache _appointmentCache = new ApiCache(TimeSpan.FromMinutes(15)); + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ApiCache _appointmentCache = new(TimeSpan.FromMinutes(15)); - public IcalCalendarService(IGeneralSettings serverSettings) + public IcalCalendarService(IGeneralSettings serverSettings, ILogger logger, IHttpClientFactory httpClientFactory) { + _logger = logger; _serverSettings = serverSettings; + _httpClientFactory = httpClientFactory; } public async Task> GetAppointments() @@ -19,7 +26,26 @@ public async Task> GetAppointments() { var appointments = new List(); - var icals = await GetCalendars(_serverSettings.Webcalendars); + List<(string? auth, string url)> cals = _serverSettings.Webcalendars.Select(x => + { + try + { + var uri = new Uri(x.Replace("webcal://", "https://")); + if (!string.IsNullOrEmpty(uri.UserInfo)) + { + var url = uri.GetComponents(UriComponents.AbsoluteUri & ~UriComponents.UserInfo, UriFormat.UriEscaped); + return (Uri.UnescapeDataString(uri.UserInfo), url); + } + return (null, x); + } + catch (UriFormatException) + { + _logger.LogError($"Invalid calendar URL: '{x}'"); + return null; + } + }).Where(x => x != null).Select(x => x!.Value).ToList(); + + var icals = await GetCalendars(cals); foreach (var ical in icals) { @@ -32,29 +58,36 @@ public async Task> GetAppointments() }); } - public async Task> GetCalendars(IEnumerable calendars) + public async Task> GetCalendars(IEnumerable<(string? auth, string url)> calendars) { var icals = new List(); + var client = _httpClientFactory.CreateClient(); - foreach (var webcal in calendars) + foreach (var calendar in calendars) { - string httpUrl = webcal.Replace("webcal://", "https://"); + _logger.LogDebug($"Loading calendar: {(calendar.auth != null ? "[authenticated]" : "no auth")} - {calendar.url}"); + + string httpUrl = calendar.url.Replace("webcal://", "https://"); - using (HttpClient client = new HttpClient()) + var request = new HttpRequestMessage(HttpMethod.Get, httpUrl); + + if (!string.IsNullOrEmpty(calendar.auth)) { - HttpResponseMessage response = await client.GetAsync(httpUrl); - if (response.IsSuccessStatusCode) - { - icals.Add(await response.Content.ReadAsStringAsync()); - } - else - { - throw new Exception("Failed to load calendar data"); - } + var byteArray = Encoding.ASCII.GetBytes(calendar.auth); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray)); + } + + HttpResponseMessage response = await client.SendAsync(request); + if (response.IsSuccessStatusCode) + { + icals.Add(await response.Content.ReadAsStringAsync()); + } + else + { + _logger.LogError($"Failed to load calendar data from '{httpUrl}' (Status: {response.StatusCode})"); } } return icals; } - } \ No newline at end of file diff --git a/docker/example.env b/docker/example.env index 75e875fd..5f0fe025 100644 --- a/docker/example.env +++ b/docker/example.env @@ -22,7 +22,7 @@ ApiKey=KEY # Albums=ALBUM1,ALBUM2 # ExcludedAlbums=ALBUM3,ALBUM4 # People=PERSON1,PERSON2 -# Webcalendars=https://calendar.mycalendar.com/basic.ics,webcal://calendar.mycalendar.com/basic.ics +# Webcalendars=https://calendar.mycalendar.com/basic.ics,https://user:pass@calendar.mycalendar.com/basic.ics # RefreshAlbumPeopleInterval=12 # ShowClock=true # ClockFormat=hh:mm diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index cf4e3295..888fd081 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -38,7 +38,9 @@ General: DownloadImages: false # boolean # if images are downloaded, re-download if age (in days) is more than this RenewImagesDuration: 30 # int - # A list of webcalendar URIs in the .ics format. e.g. https://calendar.google.com/calendar/ical/XXXXXX/public/basic.ics + # A list of webcalendar URIs in the .ics format. Supports basic auth via standard URL format. + # e.g. https://calendar.google.com/calendar/ical/XXXXXX/public/basic.ics + # e.g. https://user:pass@calendar.example.com/basic.ics Webcalendars: # string[] - UUID # Interval in hours. Determines how often images are pulled from a person in immich. @@ -159,6 +161,14 @@ Weather is enabled by entering an API key. Get yours free from [OpenWeatherMap][ ### Calendar If you are using Google Calendar, more information can be found [here](https://support.google.com/calendar/answer/37648?hl=en#zippy=%2Cget-your-calendar-view-only). +Calendar supports basic authentication using the standard URL userinfo format: +Example: +No Auth: `https://calendar.google.com/calendar/ical/XXXXXX/public/basic.ics` +With Auth: `https://username:password@calendar.google.com/calendar/ical/XXXXXX/public/basic.ics` + +### Metadata +Needs documentation + ### Misc #### Webhook A webhook to notify an external service is available. This is only enabled when the `Webhook`-Setting is set in your configuration. Your configured Webhook will be notified via `HTTP POST`-request. From fb7ce0bd61355ac4c550c75c4f240e418c049944 Mon Sep 17 00:00:00 2001 From: JW-CH <17313367+JW-CH@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:17:47 +0000 Subject: [PATCH 2/5] remove old metadata docs --- docs/docs/getting-started/configuration.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index 888fd081..5a6a29a2 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -166,9 +166,6 @@ Example: No Auth: `https://calendar.google.com/calendar/ical/XXXXXX/public/basic.ics` With Auth: `https://username:password@calendar.google.com/calendar/ical/XXXXXX/public/basic.ics` -### Metadata -Needs documentation - ### Misc #### Webhook A webhook to notify an external service is available. This is only enabled when the `Webhook`-Setting is set in your configuration. Your configured Webhook will be notified via `HTTP POST`-request. From fac3117f0ab9eed82c6e2c8e3946cdef7b944431 Mon Sep 17 00:00:00 2001 From: JW-CH <17313367+JW-CH@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:24:32 +0000 Subject: [PATCH 3/5] use UTF8 instead of ASCII --- ImmichFrame.Core/Services/IcalCalendarService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ImmichFrame.Core/Services/IcalCalendarService.cs b/ImmichFrame.Core/Services/IcalCalendarService.cs index 6599bdfd..0454097b 100644 --- a/ImmichFrame.Core/Services/IcalCalendarService.cs +++ b/ImmichFrame.Core/Services/IcalCalendarService.cs @@ -73,7 +73,7 @@ public async Task> GetCalendars(IEnumerable<(string? auth, string u if (!string.IsNullOrEmpty(calendar.auth)) { - var byteArray = Encoding.ASCII.GetBytes(calendar.auth); + var byteArray = Encoding.UTF8.GetBytes(calendar.auth); request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray)); } From 7b0069ed591382e202248387ded131489315a02f Mon Sep 17 00:00:00 2001 From: JW-CH <17313367+JW-CH@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:31:18 +0000 Subject: [PATCH 4/5] update examples --- docker/example.env | 2 +- docs/docs/getting-started/configuration.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/example.env b/docker/example.env index 5f0fe025..8e43f8df 100644 --- a/docker/example.env +++ b/docker/example.env @@ -22,7 +22,7 @@ ApiKey=KEY # Albums=ALBUM1,ALBUM2 # ExcludedAlbums=ALBUM3,ALBUM4 # People=PERSON1,PERSON2 -# Webcalendars=https://calendar.mycalendar.com/basic.ics,https://user:pass@calendar.mycalendar.com/basic.ics +# Webcalendars=https://calendar.google.com/calendar/ical/XXXXXX/public/basic.ics,https://user:pass@calendar.immichframe.dev/dav/calendars/basic.ics # RefreshAlbumPeopleInterval=12 # ShowClock=true # ClockFormat=hh:mm diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index 5a6a29a2..9b6870b0 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -40,7 +40,7 @@ General: RenewImagesDuration: 30 # int # A list of webcalendar URIs in the .ics format. Supports basic auth via standard URL format. # e.g. https://calendar.google.com/calendar/ical/XXXXXX/public/basic.ics - # e.g. https://user:pass@calendar.example.com/basic.ics + # e.g. https://user:pass@calendar.immichframe.dev/dav/calendars/basic.ics Webcalendars: # string[] - UUID # Interval in hours. Determines how often images are pulled from a person in immich. @@ -164,7 +164,7 @@ If you are using Google Calendar, more information can be found [here](https://s Calendar supports basic authentication using the standard URL userinfo format: Example: No Auth: `https://calendar.google.com/calendar/ical/XXXXXX/public/basic.ics` -With Auth: `https://username:password@calendar.google.com/calendar/ical/XXXXXX/public/basic.ics` +With Auth: `https://username:password@calendar.immichframe.dev/dav/calendars/basic.ics` ### Misc #### Webhook From 3a41d2ae88bb8109936e8c04ba1b271f6a8f139f Mon Sep 17 00:00:00 2001 From: JW-CH <17313367+JW-CH@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:35:28 +0000 Subject: [PATCH 5/5] using pattern for request and response --- ImmichFrame.Core/Services/IcalCalendarService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ImmichFrame.Core/Services/IcalCalendarService.cs b/ImmichFrame.Core/Services/IcalCalendarService.cs index 0454097b..b17a9317 100644 --- a/ImmichFrame.Core/Services/IcalCalendarService.cs +++ b/ImmichFrame.Core/Services/IcalCalendarService.cs @@ -69,7 +69,7 @@ public async Task> GetCalendars(IEnumerable<(string? auth, string u string httpUrl = calendar.url.Replace("webcal://", "https://"); - var request = new HttpRequestMessage(HttpMethod.Get, httpUrl); + using var request = new HttpRequestMessage(HttpMethod.Get, httpUrl); if (!string.IsNullOrEmpty(calendar.auth)) { @@ -77,7 +77,7 @@ public async Task> GetCalendars(IEnumerable<(string? auth, string u request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray)); } - HttpResponseMessage response = await client.SendAsync(request); + using var response = await client.SendAsync(request); if (response.IsSuccessStatusCode) { icals.Add(await response.Content.ReadAsStringAsync());