// Copyright (c) 2026 Duplicati Inc. All rights reserved. using System.Text; using System.Text.Json; namespace Duplicati.Proprietary.Office365; partial class SourceProvider { internal SharePointListApiImpl SharePointListApi => new SharePointListApiImpl(_apiHelper); internal class SharePointListApiImpl(APIHelper provider) { internal IAsyncEnumerable ListListsAsync(string siteId, CancellationToken ct) { var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); var select = GraphSelectBuilder.BuildSelect(); var site = Uri.EscapeDataString(siteId); var url = $"{baseUrl}/v1.0/sites/{site}/lists" + $"?$select={Uri.EscapeDataString(select)}" + $"&$top={APIHelper.GENERAL_PAGE_SIZE}"; return provider.GetAllGraphItemsAsync(url, ct); } internal IAsyncEnumerable ListListItemsAsync(string siteId, string listId, CancellationToken ct) { var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); // Exclude 'fields' from select because we expand it var select = GraphSelectBuilder.BuildSelect(["fields"]); var site = Uri.EscapeDataString(siteId); var list = Uri.EscapeDataString(listId); var url = $"{baseUrl}/v1.0/sites/{site}/lists/{list}/items" + $"?$select={Uri.EscapeDataString(select)}" + $"&$expand=fields" + $"&$top={APIHelper.GENERAL_PAGE_SIZE}"; return provider.GetAllGraphItemsAsync(url, ct); } internal async IAsyncEnumerable ListListItemAttachmentsAsync( string siteWebUrl, string listId, string itemId, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) { // SharePoint REST: /_api/web/lists(guid'...')/items()/AttachmentFiles var webUrl = siteWebUrl.TrimEnd('/'); var listGuid = listId.Trim('{', '}'); var url = $"{webUrl}/_api/web/lists(guid'{Uri.EscapeDataString(listGuid)}')" + $"/items({Uri.EscapeDataString(itemId)})/AttachmentFiles"; using var stream = await provider.GetGraphItemAsStreamAsync( url, "application/json", "odata=nometadata", ct).ConfigureAwait(false); using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false); JsonElement items; if (doc.RootElement.TryGetProperty("value", out var v) && v.ValueKind == JsonValueKind.Array) { items = v; } else if (doc.RootElement.TryGetProperty("d", out var d) && d.TryGetProperty("results", out var r) && r.ValueKind == JsonValueKind.Array) { items = r; } else { yield break; } foreach (var el in items.EnumerateArray()) { if (!el.TryGetProperty("FileName", out var fn)) continue; var fileName = fn.GetString(); if (string.IsNullOrWhiteSpace(fileName)) continue; yield return new GraphAttachment { // For SharePoint list-item attachments, the filename is the identifier Id = fileName!, Name = fileName }; } } internal Task GetAttachmentContentStreamAsync( string siteWebUrl, string listId, string itemId, string attachmentFileName, CancellationToken ct) { // SharePoint REST: // /_api/web/lists(guid'...')/items()/AttachmentFiles('')/$value var webUrl = siteWebUrl.TrimEnd('/'); var listGuid = listId.Trim('{', '}'); // Escape single quotes for OData var safeFileName = attachmentFileName.Replace("'", "''"); var url = $"{webUrl}/_api/web/lists(guid'{Uri.EscapeDataString(listGuid)}')" + $"/items({Uri.EscapeDataString(itemId)})" + $"/AttachmentFiles('{Uri.EscapeDataString(safeFileName)}')/$value"; return provider.GetGraphResponseAsRealStreamAsync(url, "application/octet-stream", ct); } internal async Task CreateListAsync(string siteId, string displayName, GraphListInfo? listInfo, CancellationToken ct) { var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); var site = Uri.EscapeDataString(siteId); var url = $"{baseUrl}/v1.0/sites/{site}/lists"; var list = new GraphList { DisplayName = displayName, List = listInfo ?? new GraphListInfo { Template = "genericList" } }; var json = JsonSerializer.Serialize(list); var content = new StringContent(json, Encoding.UTF8, "application/json"); return await provider.PostGraphItemAsync(url, content, ct).ConfigureAwait(false); } internal async Task GetListAsync(string siteId, string displayName, CancellationToken ct) { var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); var site = Uri.EscapeDataString(siteId); var select = GraphSelectBuilder.BuildSelect(); // Filter by displayName var filter = $"displayName eq '{displayName.Replace("'", "''")}'"; var url = $"{baseUrl}/v1.0/sites/{site}/lists" + $"?$filter={Uri.EscapeDataString(filter)}" + $"&$select={Uri.EscapeDataString(select)}"; var lists = await provider.GetAllGraphItemsAsync(url, ct).ToListAsync(ct).ConfigureAwait(false); return lists.FirstOrDefault(); } internal async Task IsDocumentLibraryAsync(string siteId, string listId, CancellationToken ct) { var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); var site = Uri.EscapeDataString(siteId); var list = Uri.EscapeDataString(listId); // list.template is available under the "list" facet var url = $"{baseUrl}/v1.0/sites/{site}/lists/{list}?$select=list"; async Task requestFactory(CancellationToken rct) { var req = new HttpRequestMessage(HttpMethod.Get, new Uri(url)); req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false); return req; } using var resp = await provider.SendWithRetryShortAsync(requestFactory, ct).ConfigureAwait(false); await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false); await using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false); if (!doc.RootElement.TryGetProperty("list", out var listFacet) || listFacet.ValueKind != JsonValueKind.Object) return false; if (!listFacet.TryGetProperty("template", out var templateProp) || templateProp.ValueKind != JsonValueKind.String) return false; var template = templateProp.GetString(); return string.Equals(template, "documentLibrary", StringComparison.OrdinalIgnoreCase); } internal async Task GetDriveIdForListAsync(string siteId, string listId, CancellationToken ct) { var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); var site = Uri.EscapeDataString(siteId); var list = Uri.EscapeDataString(listId); // Works only for document libraries; non-libraries typically return 404. var url = $"{baseUrl}/v1.0/sites/{site}/lists/{list}/drive?$select=id"; async Task requestFactory(CancellationToken rct) { var req = new HttpRequestMessage(HttpMethod.Get, new Uri(url)); req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false); return req; } using var resp = await provider.SendWithRetryShortAsync(requestFactory, ct).ConfigureAwait(false); if (resp.StatusCode == System.Net.HttpStatusCode.NotFound) return null; await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false); await using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false); if (doc.RootElement.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String) return idProp.GetString(); return null; } internal Task GetItemContentStreamAsync(string siteId, string listId, string itemId, CancellationToken ct) { var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); var site = Uri.EscapeDataString(siteId); var list = Uri.EscapeDataString(listId); var item = Uri.EscapeDataString(itemId); var url = $"{baseUrl}/v1.0/sites/{site}/lists/{list}/items/{item}/driveItem/content"; return provider.GetGraphResponseAsRealStreamAsync(url, "application/octet-stream", ct); } } }