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;
+}
|