summary refs log tree commit diff
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2026-08-02 01:00:21 +0200
committerRory& <root@rory.gay>2026-08-02 01:00:21 +0200
commitffd7f00336b65d52b0a4587421b4393ce67e717c (patch)
treeb1e5585e5049851ca502d92776569295ce9389e0
parentBasic retry logic for UnixSocketWriter connections before unlinking sockets (diff)
downloadserver-ts-ffd7f00336b65d52b0a4587421b4393ce67e717c.tar.xz
Add attachment list API
-rw-r--r--assets/openapi.json118
-rw-r--r--assets/schemas.json67
-rw-r--r--extra/admin-api/Models/Spacebar.Models.Api/CreateAttachmentRequest.cs17
-rw-r--r--extra/admin-api/Models/Spacebar.Models.Api/Spacebar.Models.Api.csproj5
-rw-r--r--extra/admin-api/Tests/Spacebar.Tests/Tests/ChannelTests.cs73
-rw-r--r--src/api/routes_toplevel/_spacebar/api/v1/channels/#channel_id/attachments.ts97
-rw-r--r--src/schemas/api/messages/Message.ts5
-rw-r--r--src/schemas/api/spacebar/AttachmentListResponse.ts28
8 files changed, 390 insertions, 20 deletions
diff --git a/assets/openapi.json b/assets/openapi.json

index 8ddf135f..add88948 100644 --- a/assets/openapi.json +++ b/assets/openapi.json
@@ -7367,6 +7367,37 @@ } } }, + "AttachmentListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "attachment": { + "$ref": "#/components/schemas/PublicAttachment" + }, + "message_reference": { + "$ref": "#/components/schemas/MessageReferenceIds" + } + }, + "additionalProperties": false, + "required": [ + "attachment", + "message_reference" + ] + } + }, + "total": { + "type": "integer" + } + }, + "required": [ + "items", + "total" + ] + }, "GifIntegrationsResponse": { "type": "object", "properties": { @@ -14085,15 +14116,6 @@ "MessageReference": { "type": "object", "properties": { - "message_id": { - "type": "string" - }, - "channel_id": { - "type": "string" - }, - "guild_id": { - "type": "string" - }, "fail_if_not_exists": { "type": "boolean" }, @@ -14103,6 +14125,15 @@ 1 ], "type": "number" + }, + "message_id": { + "type": "string" + }, + "channel_id": { + "type": "string" + }, + "guild_id": { + "type": "string" } } }, @@ -15346,6 +15377,20 @@ "icon", "id" ] + }, + "MessageReferenceIds": { + "type": "object", + "properties": { + "message_id": { + "type": "string" + }, + "channel_id": { + "type": "string" + }, + "guild_id": { + "type": "string" + } + } } } }, @@ -29795,6 +29840,61 @@ ] } }, + "/_spacebar/api/v1/channels/{channel_id}/attachments/": { + "get": { + "security": [ + { + "bearer": [] + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttachmentListResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIErrorResponse" + } + } + } + }, + "422": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIErrorResponse" + } + } + } + } + }, + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "channel_id" + } + ], + "tags": [ + "_spacebar" + ] + } + }, "/_spacebar/api/schemas.json/": { "get": { "security": [ diff --git a/assets/schemas.json b/assets/schemas.json
index acf04618..d0a10a70 100644 --- a/assets/schemas.json +++ b/assets/schemas.json
@@ -7855,6 +7855,39 @@ "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" }, + "AttachmentListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "attachment": { + "$ref": "#/definitions/PublicAttachment" + }, + "message_reference": { + "$ref": "#/definitions/MessageReferenceIds" + } + }, + "additionalProperties": false, + "required": [ + "attachment", + "message_reference" + ] + } + }, + "total": { + "type": "integer" + } + }, + "additionalProperties": false, + "required": [ + "items", + "total" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, "GifIntegrationsResponse": { "type": "object", "properties": { @@ -14978,15 +15011,6 @@ "MessageReference": { "type": "object", "properties": { - "message_id": { - "type": "string" - }, - "channel_id": { - "type": "string" - }, - "guild_id": { - "type": "string" - }, "fail_if_not_exists": { "type": "boolean" }, @@ -14996,6 +15020,15 @@ 1 ], "type": "number" + }, + "message_id": { + "type": "string" + }, + "channel_id": { + "type": "string" + }, + "guild_id": { + "type": "string" } }, "additionalProperties": false, @@ -16352,5 +16385,21 @@ "id" ], "$schema": "http://json-schema.org/draft-07/schema#" + }, + "MessageReferenceIds": { + "type": "object", + "properties": { + "message_id": { + "type": "string" + }, + "channel_id": { + "type": "string" + }, + "guild_id": { + "type": "string" + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" } } \ No newline at end of file diff --git a/extra/admin-api/Models/Spacebar.Models.Api/CreateAttachmentRequest.cs b/extra/admin-api/Models/Spacebar.Models.Api/CreateAttachmentRequest.cs
index 8bc4a2d8..be04cd4e 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<Entry> 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 237d6616..ac6bad9f 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 @@ <Nullable>enable</Nullable> </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\Spacebar.Models.Generic\Spacebar.Models.Generic.csproj" Condition="'$(ContinuousIntegrationBuild)'!='true'"/> + <PackageReference Include="Spacebar.Models.Generic" Version="*-preview*" Condition="'$(ContinuousIntegrationBuild)'=='true'"/> + </ItemGroup> + </Project> diff --git a/extra/admin-api/Tests/Spacebar.Tests/Tests/ChannelTests.cs b/extra/admin-api/Tests/Spacebar.Tests/Tests/ChannelTests.cs
index 080a991e..2af02fcf 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<JsonObject>(cancellationToken: TestContext.Current.CancellationToken); + // testOutputHelper.WriteLine(createAttRespContent?.ToString()); + + var createAtt = createAttRespContent.Deserialize<CreateAttachmentResponse>(); + 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<JsonObject>(cancellationToken: TestContext.Current.CancellationToken)); + // testOutputHelper.WriteLine(json.ToJson(indent: true)); + var msg = json.Deserialize<Message>(); + 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<JsonObject>(cancellationToken: TestContext.Current.CancellationToken); + var attList = attListJson.Deserialize<AttachmentListResponse>()!; + 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 00000000..b1a0d313 --- /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 <https://www.gnu.org/licenses/>. +*/ + +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 bc2bd447..eed8d2ad 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 00000000..fe10bde7 --- /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 <https://www.gnu.org/licenses/>. +*/ + +import { PublicAttachment } from "@spacebar/schemas/api/messages/Attachments"; +import { MessageReferenceIds } from "@spacebar/schemas"; + +export interface AttachmentListResponse { + items: { + attachment: PublicAttachment; + message_reference: MessageReferenceIds; + }[]; + total: number; +}