diff --git a/assets/openapi.json b/assets/openapi.json index 8ddf135f0..add889486 100644 Binary files a/assets/openapi.json and b/assets/openapi.json differ diff --git a/assets/schemas.json b/assets/schemas.json index acf04618f..d0a10a706 100644 Binary files a/assets/schemas.json and b/assets/schemas.json differ diff --git a/extra/admin-api/Models/Spacebar.Models.Api/CreateAttachmentRequest.cs b/extra/admin-api/Models/Spacebar.Models.Api/CreateAttachmentRequest.cs index 8bc4a2d8c..be04cd4e8 100644 --- a/extra/admin-api/Models/Spacebar.Models.Api/CreateAttachmentRequest.cs +++ b/extra/admin-api/Models/Spacebar.Models.Api/CreateAttachmentRequest.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Spacebar.Models.Generic; namespace Spacebar.Models.Api; @@ -38,4 +39,20 @@ public class CreateAttachmentResponse { [JsonPropertyName("original_content_type")] public string? OriginalContentType { get; set; } } +} + +public class AttachmentListResponse { + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("items")] + public List Items { get; set; } + + public class Entry { + [JsonPropertyName("attachment")] + public MessageAttachment Attachment { get; set; } + + [JsonPropertyName("message_reference")] + public MessageReference MessageReference { get; set; } + } } \ No newline at end of file diff --git a/extra/admin-api/Models/Spacebar.Models.Api/Spacebar.Models.Api.csproj b/extra/admin-api/Models/Spacebar.Models.Api/Spacebar.Models.Api.csproj index 237d66167..ac6bad9f0 100644 --- a/extra/admin-api/Models/Spacebar.Models.Api/Spacebar.Models.Api.csproj +++ b/extra/admin-api/Models/Spacebar.Models.Api/Spacebar.Models.Api.csproj @@ -6,4 +6,9 @@ enable + + + + + diff --git a/extra/admin-api/Tests/Spacebar.Tests/Tests/ChannelTests.cs b/extra/admin-api/Tests/Spacebar.Tests/Tests/ChannelTests.cs index 080a991e9..2af02fcfd 100644 --- a/extra/admin-api/Tests/Spacebar.Tests/Tests/ChannelTests.cs +++ b/extra/admin-api/Tests/Spacebar.Tests/Tests/ChannelTests.cs @@ -1,5 +1,8 @@ -using System.Net.Http.Json; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; using System.Text.Json.Nodes; +using Spacebar.Models.Api; using Spacebar.Models.Generic; using Spacebar.Sdk.Core; using Spacebar.Tests.Abstractions; @@ -84,4 +87,72 @@ public class ChannelTests(ITestOutputHelper testOutputHelper, TestFixture fixtur await Assert.HttpSuccess(await Client.ApiHttpClient.DeleteAsync($"channels/{c.Id}", TestContext.Current.CancellationToken)); } + + [Fact] + public async Task GetAttachmentList() { + var channel = await Client!.GetGuild(Guild.Id).CreateChannelAsync(new() { + Name = "test", + Type = 0 // TODO: this should be the default + }); + + Assert.Equal("test", channel.Name); + + var createAttResp = await Assert.HttpSuccess(await Client.ApiHttpClient.PostAsJsonAsync($"channels/{channel.Id}/attachments", new CreateAttachmentRequest() { + Files = [ + new() { + Id = 0, + FileName = "hellorld.txt", + FileSize = "Hellorld!".Length + } + ] + }, cancellationToken: TestContext.Current.CancellationToken)); + var createAttRespContent = await createAttResp.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + // testOutputHelper.WriteLine(createAttRespContent?.ToString()); + + var createAtt = createAttRespContent.Deserialize(); + foreach (var attFile in createAtt.Attachments) + await Assert.HttpSuccess(await Client.ApiHttpClient.PutAsync(attFile.UploadUrl, new ByteArrayContent("Hellorld!"u8.ToArray()) { + Headers = { + ContentType = new MediaTypeHeaderValue("text/plain") + } + }, TestContext.Current.CancellationToken)); + + var content = new JsonObject() { + { "content", "meow" }, { + "attachments", new JsonArray() { + new JsonObject() { + { "id", createAtt.Attachments[0].Id.ToString() }, + { "filename", "hellorld.txt" }, + { "uploaded_filename", createAtt.Attachments[0].UploadFileName }, + { "original_content_type", "text/plain" }, + } + } + } + }; + var res = await Assert.HttpSuccess(await Client.ApiHttpClient.PostAsJsonAsync($"channels/{channel.Id}/messages", content, + cancellationToken: TestContext.Current.CancellationToken)); + var json = (await res.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken)); + // testOutputHelper.WriteLine(json.ToJson(indent: true)); + var msg = json.Deserialize(); + Assert.Equal("meow", msg.Content); + Assert.Single(msg.Attachments); + + var attListResp = await Assert.HttpSuccess(await Client.ApiHttpClient.GetAsync($"/_spacebar/api/v1/channels/{channel.Id}/attachments", TestContext.Current.CancellationToken)); + var attListJson = await attListResp.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + var attList = attListJson.Deserialize()!; + Assert.Equal(1, attList.Total); + Assert.All(attList.Items, e => { + Assert.Equal(channel.Id, e.MessageReference.ChannelId); + Assert.Equal(msg.Id, e.MessageReference.MessageId); + Assert.Equal(Guild.Id, e.MessageReference.GuildId); + + Assert.StringNotNullOrWhitespace(e.Attachment.ContentType); + Assert.StringNotNullOrWhitespace(e.Attachment.Filename); + Assert.StringNotNullOrWhitespace(e.Attachment.ProxyUrl); + Assert.StringNotNullOrWhitespace(e.Attachment.Url); + Assert.NotEqual(0, e.Attachment.Size); + Assert.Null(e.Attachment.Width); + Assert.Null(e.Attachment.Height); + }); + } } \ No newline at end of file diff --git a/src/api/routes_toplevel/_spacebar/api/v1/channels/#channel_id/attachments.ts b/src/api/routes_toplevel/_spacebar/api/v1/channels/#channel_id/attachments.ts new file mode 100644 index 000000000..b1a0d313e --- /dev/null +++ b/src/api/routes_toplevel/_spacebar/api/v1/channels/#channel_id/attachments.ts @@ -0,0 +1,97 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { Request, Response, Router } from "express"; +import { HTTPError } from "lambert-server/HTTPError"; +import { FindOptionsOrderValue, LessThan, MoreThan } from "typeorm"; +import { route } from "@spacebar/api/middlewares"; +import { Attachment, Channel, Message } from "@spacebar/database"; +import { FieldErrors, getPermission } from "@spacebar/util"; +import { AttachmentListResponse } from "@spacebar/schemas/api/spacebar/AttachmentListResponse"; + +const router: Router = Router({ mergeParams: true }); + +router.get( + "/", + route({ + responses: { + 200: { + body: "AttachmentListResponse", + }, + 403: { + body: "APIErrorResponse", + }, + 422: { + body: "APIErrorResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const { channel_id } = req.params as { [key: string]: string }; + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + const { sort_order, limit } = req.query; + + const parsedLimit = Number(limit) || 50; + if (parsedLimit < 1 || parsedLimit > 100) throw new HTTPError("limit must be between 1 and 100", 422); + + if (sort_order) { + if (typeof sort_order != "string" || ["desc", "asc"].indexOf(sort_order) == -1) + throw FieldErrors({ + sort_order: { + message: "Value must be one of ('desc', 'asc').", + code: "BASE_TYPE_CHOICES", + }, + }); // todo this is wrong + } + + const permissions = await getPermission(req.user_id, channel.guild_id, channel_id as string | undefined); + permissions.hasThrow("VIEW_CHANNEL"); + if (!permissions.has("READ_MESSAGE_HISTORY")) return res.json({ items: [], total: 0 } satisfies AttachmentListResponse); + + const attCount = Attachment.count({ where: { channel_id: req.params.channel_id as string } }); + const atts = await Attachment.find({ + where: { + channel_id: req.params.channel_id as string, + id: (sort_order == "asc" ? MoreThan : LessThan)((req.params.after as string) ?? "0"), + }, + order: { + id: sort_order as FindOptionsOrderValue, + }, + take: parsedLimit, + }); + + return res.json({ + items: atts.map((x) => ({ + attachment: x.signUrls({ + userAgent: req.headers["user-agent"], + ip: req.ip, + }), + message_reference: { + message_id: x.message_id, + channel_id: x.channel_id, + guild_id: channel.guild_id, + }, + })), + total: await attCount, + } satisfies AttachmentListResponse); + }, +); + +export default router; diff --git a/src/schemas/api/messages/Message.ts b/src/schemas/api/messages/Message.ts index bc2bd447b..eed8d2ad2 100644 --- a/src/schemas/api/messages/Message.ts +++ b/src/schemas/api/messages/Message.ts @@ -234,10 +234,13 @@ export enum PotionType { CONFETTI = 0, } -export interface MessageReference { +export interface MessageReferenceIds { message_id?: string; channel_id?: string; guild_id?: string; +} + +export interface MessageReference extends MessageReferenceIds { fail_if_not_exists?: boolean; type?: MessageReferenceType; } diff --git a/src/schemas/api/spacebar/AttachmentListResponse.ts b/src/schemas/api/spacebar/AttachmentListResponse.ts new file mode 100644 index 000000000..fe10bde7a --- /dev/null +++ b/src/schemas/api/spacebar/AttachmentListResponse.ts @@ -0,0 +1,28 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { PublicAttachment } from "@spacebar/schemas/api/messages/Attachments"; +import { MessageReferenceIds } from "@spacebar/schemas"; + +export interface AttachmentListResponse { + items: { + attachment: PublicAttachment; + message_reference: MessageReferenceIds; + }[]; + total: number; +}