Iceshrimp.NET/Iceshrimp.Backend/Core/Services/ObjectStorageService.cs
Laura Hausmann 0668c6b360
[backend/drive] Improve media filename handling
This commit adds support for remote Content-Disposition headers giving information on attachment filenames, as well as providing this information when serving locally stored files. It also passes on any filename metadata to the object storage provider, if configured as the storage backend.
2024-07-09 23:44:34 +02:00

149 lines
No EOL
5 KiB
C#

using System.Collections.Immutable;
using System.Net.Mime;
using System.Text;
using Carbon.Storage;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.ObjectStorage.Core.Models;
using Iceshrimp.ObjectStorage.Core.Security;
using Iceshrimp.ObjectStorage.S3.Client;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Core.Services;
public class ObjectStorageService(IOptions<Config.StorageSection> config, HttpClient httpClient)
{
private readonly string? _accessUrl = config.Value.ObjectStorage?.AccessUrl;
private readonly IReadOnlyDictionary<string, string>? _acl = config.Value.ObjectStorage?.SetAcl != null
? new Dictionary<string, string> { { "x-amz-acl", config.Value.ObjectStorage.SetAcl } }.AsReadOnly()
: null;
private readonly S3Bucket? _bucket = GetBucketSafely(config);
private readonly string? _prefix = config.Value.ObjectStorage?.Prefix?.Trim('/');
private static S3Bucket? GetBucketSafely(IOptions<Config.StorageSection> config)
{
if (config.Value.Provider != Enums.FileStorage.Local) return GetBucket(config);
try
{
return GetBucket(config);
}
catch
{
return null;
}
}
private static S3Bucket GetBucket(IOptions<Config.StorageSection> config)
{
var s3Config = config.Value.ObjectStorage ?? throw new Exception("Invalid object storage configuration");
var region = s3Config.Region ?? throw new Exception("Invalid object storage region");
var endpoint = s3Config.Endpoint ?? throw new Exception("Invalid object storage endpoint");
var accessKey = s3Config.KeyId ?? throw new Exception("Invalid object storage access key");
var secretKey = s3Config.SecretKey ?? throw new Exception("Invalid object storage secret key");
var bucket = s3Config.Bucket ?? throw new Exception("Invalid object storage bucket");
if (config.Value.ObjectStorage?.AccessUrl == null)
throw new Exception("Invalid object storage access url");
var client = new S3Client(new AwsRegion(region), endpoint, new AwsCredential(accessKey, secretKey));
return new S3Bucket(bucket, client);
}
public async Task VerifyCredentialsAsync()
{
if (config.Value.ObjectStorage?.DisableValidation ?? false)
return;
const string filename = ".iceshrimp-test";
var content = CryptographyHelpers.GenerateRandomString(16);
await UploadFileAsync(filename, "text/plain", filename, Encoding.UTF8.GetBytes(content));
string result;
try
{
result = await httpClient.GetStringAsync(GetFilePublicUrl(filename));
}
catch (Exception e)
{
throw new Exception($"Failed to verify access url: {e.Message}");
}
if (result == content)
return;
throw new Exception("Failed to verify access url (content mismatch)");
}
private Task UploadFileAsync(string key, string contentType, string filename, byte[] data) =>
UploadFileAsync(key, contentType, filename, new MemoryStream(data));
public async Task UploadFileAsync(string key, string contentType, string filename, Stream data)
{
if (_bucket == null) throw new Exception("Refusing to upload to object storage with invalid configuration");
var properties = (_acl ?? BlobProperties.Empty).ToDictionary();
properties.Add("Content-Type", contentType);
properties.Add("Content-Disposition", new ContentDisposition("inline") { FileName = filename }.ToString());
IBlob blob = data.Length > 0
? new Blob(GetKeyWithPrefix(key), data, properties)
: new EmptyBlob(GetKeyWithPrefix(key), data, properties);
await _bucket.PutAsync(blob);
}
public Uri GetFilePublicUrl(string filename)
{
var baseUri = new Uri(_accessUrl ?? throw new Exception("Invalid object storage access url"));
return new Uri(baseUri, GetKeyWithPrefix(filename));
}
public async ValueTask<Stream?> GetFileAsync(string filename)
{
if (_bucket == null) throw new Exception("Refusing to get file from object storage with invalid configuration");
try
{
var res = await _bucket.GetAsync(GetKeyWithPrefix(filename));
return await res.OpenAsync();
}
catch
{
return null;
}
}
public async Task RemoveFilesAsync(params string[] filenames)
{
if (_bucket == null)
throw new Exception("Refusing to remove file from object storage with invalid configuration");
await _bucket.DeleteAsync(filenames.Select(GetKeyWithPrefix).ToImmutableList());
}
private string GetKeyWithPrefix(string filename)
{
return !string.IsNullOrWhiteSpace(_prefix) ? _prefix + "/" + filename : filename;
}
private class EmptyBlob(string key, Stream stream, IReadOnlyDictionary<string, string> properties) : IBlob
{
private bool _isDisposed;
public void Dispose()
{
if (_isDisposed) return;
stream.Dispose();
_isDisposed = true;
}
public ValueTask<Stream> OpenAsync() => ValueTask.FromResult(stream);
public string Key => key;
public long Size => 0;
public DateTime Modified => DateTime.UtcNow;
public IReadOnlyDictionary<string, string> Properties => properties;
}
}