236 lines
9.8 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|