Files
duplicati/proprietary/Office365/SourceProvider/SourceProvider.SharePointLists.cs
T

236 lines
9.8 KiB
C#

// 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<GraphList> ListListsAsync(string siteId, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var select = GraphSelectBuilder.BuildSelect<GraphList>();
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<GraphList>(url, ct);
}
internal IAsyncEnumerable<GraphListItem> ListListItemsAsync(string siteId, string listId, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
// Exclude 'fields' from select because we expand it
var select = GraphSelectBuilder.BuildSelect<GraphListItem>(["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<GraphListItem>(url, ct);
}
internal async IAsyncEnumerable<GraphAttachment> ListListItemAttachmentsAsync(
string siteWebUrl,
string listId,
string itemId,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
// SharePoint REST: /_api/web/lists(guid'...')/items(<id>)/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<Stream> GetAttachmentContentStreamAsync(
string siteWebUrl,
string listId,
string itemId,
string attachmentFileName,
CancellationToken ct)
{
// SharePoint REST:
// /_api/web/lists(guid'...')/items(<id>)/AttachmentFiles('<filename>')/$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<GraphList> 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<GraphList>(url, content, ct).ConfigureAwait(false);
}
internal async Task<GraphList?> GetListAsync(string siteId, string displayName, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var site = Uri.EscapeDataString(siteId);
var select = GraphSelectBuilder.BuildSelect<GraphList>();
// 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<GraphList>(url, ct).ToListAsync(ct).ConfigureAwait(false);
return lists.FirstOrDefault();
}
internal async Task<bool> 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<HttpRequestMessage> 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<string?> 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<HttpRequestMessage> 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<Stream> 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);
}
}
}