Implement signed cdn urls
This commit is contained in:
parent
1d8e081fd8
commit
080b2c7d38
@ -212,6 +212,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"GuildSubscriptionSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"channels": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activities": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"threads": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"typing": {
|
||||||
|
"enum": [
|
||||||
|
true
|
||||||
|
],
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"members": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"member_updates": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"thread_member_lists": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ActivitySchema": {
|
"ActivitySchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -3972,6 +4014,21 @@
|
|||||||
"widget_enabled"
|
"widget_enabled"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"RefreshedUrl": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"original": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"refreshed": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"original",
|
||||||
|
"refreshed"
|
||||||
|
]
|
||||||
|
},
|
||||||
"TenorGifResponse": {
|
"TenorGifResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -4137,6 +4194,12 @@
|
|||||||
"$ref": "#/components/schemas/Channel"
|
"$ref": "#/components/schemas/Channel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"members": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Member"
|
||||||
|
}
|
||||||
|
},
|
||||||
"region": {
|
"region": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@ -4215,12 +4278,6 @@
|
|||||||
"presence_count": {
|
"presence_count": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"members": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/Member"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"template_id": {
|
"template_id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@ -4620,9 +4677,7 @@
|
|||||||
"channel": {
|
"channel": {
|
||||||
"$ref": "#/components/schemas/RateLimitOptions"
|
"$ref": "#/components/schemas/RateLimitOptions"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {}
|
||||||
"$ref": "#/components/schemas/AuthRateLimit"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"auth",
|
"auth",
|
||||||
@ -4631,21 +4686,6 @@
|
|||||||
"webhook"
|
"webhook"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"AuthRateLimit": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"login": {
|
|
||||||
"$ref": "#/components/schemas/RateLimitOptions"
|
|
||||||
},
|
|
||||||
"register": {
|
|
||||||
"$ref": "#/components/schemas/RateLimitOptions"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"login",
|
|
||||||
"register"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"GlobalRateLimits": {
|
"GlobalRateLimits": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -5390,6 +5430,68 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"LazyRequestSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"guild_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activities": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"threads": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"typing": {
|
||||||
|
"enum": [
|
||||||
|
true
|
||||||
|
],
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"members": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"member_updates": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"thread_member_lists": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"guild_id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GuildSubscriptionsBulkSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"subscriptions": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"$ref": "#/components/schemas/GuildSubscriptionSchema"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"subscriptions"
|
||||||
|
]
|
||||||
|
},
|
||||||
"GuildTemplateCreateSchema": {
|
"GuildTemplateCreateSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -5740,51 +5842,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"LazyRequestSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"guild_id": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"channels": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"activities": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"threads": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"typing": {
|
|
||||||
"enum": [
|
|
||||||
true
|
|
||||||
],
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"members": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"thread_member_lists": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"guild_id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"LoginSchema": {
|
"LoginSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -6249,6 +6306,20 @@
|
|||||||
"before"
|
"before"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"RefreshUrlsRequestSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"attachment_urls": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"attachment_urls"
|
||||||
|
]
|
||||||
|
},
|
||||||
"RegisterSchema": {
|
"RegisterSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -7090,6 +7161,20 @@
|
|||||||
"location"
|
"location"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"RefreshUrlsResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"refreshed_urls": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/RefreshedUrl"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"refreshed_urls"
|
||||||
|
]
|
||||||
|
},
|
||||||
"TeamListResponse": {
|
"TeamListResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"patternProperties": {
|
"patternProperties": {
|
||||||
@ -9055,6 +9140,9 @@
|
|||||||
{
|
{
|
||||||
"name": "applications"
|
"name": "applications"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "attachments"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "auth"
|
"name": "auth"
|
||||||
},
|
},
|
||||||
@ -11652,6 +11740,25 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/policies/instance/config/": {
|
||||||
|
"get": {
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"policies"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/ping/": {
|
"/ping/": {
|
||||||
"get": {
|
"get": {
|
||||||
"responses": {
|
"responses": {
|
||||||
@ -18302,6 +18409,50 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/attachments/refresh-urls/": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RefreshUrlsRequestSchema"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RefreshUrlsResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/APIErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"attachments"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/applications/": {
|
"/applications/": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
|
15494
assets/schemas.json
15494
assets/schemas.json
File diff suppressed because it is too large
Load Diff
1
package-lock.json
generated
1
package-lock.json
generated
@ -36,6 +36,7 @@
|
|||||||
"missing-native-js-functions": "^1.4.3",
|
"missing-native-js-functions": "^1.4.3",
|
||||||
"module-alias": "^2.2.3",
|
"module-alias": "^2.2.3",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"ms": "^2.1.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"murmurhash-js": "^1.0.0",
|
"murmurhash-js": "^1.0.0",
|
||||||
"node-2fa": "^2.0.3",
|
"node-2fa": "^2.0.3",
|
||||||
|
@ -96,6 +96,7 @@
|
|||||||
"missing-native-js-functions": "^1.4.3",
|
"missing-native-js-functions": "^1.4.3",
|
||||||
"module-alias": "^2.2.3",
|
"module-alias": "^2.2.3",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"ms": "^2.1.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"murmurhash-js": "^1.0.0",
|
"murmurhash-js": "^1.0.0",
|
||||||
"node-2fa": "^2.0.3",
|
"node-2fa": "^2.0.3",
|
||||||
|
48
src/api/routes/attachments/refresh-urls.ts
Normal file
48
src/api/routes/attachments/refresh-urls.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
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 { route } from "@spacebar/api";
|
||||||
|
import { RefreshUrlsRequestSchema, resignUrl } from "@spacebar/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "RefreshUrlsRequestSchema",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "RefreshUrlsResponse",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { attachment_urls } = req.body as RefreshUrlsRequestSchema;
|
||||||
|
|
||||||
|
const refreshed_urls = attachment_urls.map(resignUrl);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
refreshed_urls,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
@ -35,6 +35,7 @@ import {
|
|||||||
emitEvent,
|
emitEvent,
|
||||||
getPermission,
|
getPermission,
|
||||||
isTextChannel,
|
isTextChannel,
|
||||||
|
resignUrl,
|
||||||
uploadFile,
|
uploadFile,
|
||||||
} from "@spacebar/util";
|
} from "@spacebar/util";
|
||||||
import { Request, Response, Router } from "express";
|
import { Request, Response, Router } from "express";
|
||||||
@ -199,16 +200,18 @@ router.get(
|
|||||||
? y.proxy_url
|
? y.proxy_url
|
||||||
: `https://example.org${y.proxy_url}`;
|
: `https://example.org${y.proxy_url}`;
|
||||||
|
|
||||||
let pathname = new URL(uri).pathname;
|
const url = new URL(uri);
|
||||||
while (
|
if (endpoint) {
|
||||||
pathname.split("/")[0] != "attachments" &&
|
const newBase = new URL(endpoint);
|
||||||
pathname.length > 30
|
url.protocol = newBase.protocol;
|
||||||
) {
|
url.hostname = newBase.hostname;
|
||||||
pathname = pathname.split("/").slice(1).join("/");
|
url.port = newBase.port;
|
||||||
}
|
}
|
||||||
if (!endpoint?.endsWith("/")) pathname = "/" + pathname;
|
|
||||||
|
|
||||||
y.proxy_url = `${endpoint == null ? "" : endpoint}${pathname}`;
|
y.proxy_url = url.toString();
|
||||||
|
|
||||||
|
y.proxy_url = resignUrl(y.proxy_url);
|
||||||
|
y.url = resignUrl(y.url);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -16,13 +16,18 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router, Response, Request } from "express";
|
import {
|
||||||
import { Config, Snowflake } from "@spacebar/util";
|
Config,
|
||||||
import { storage } from "../util/Storage";
|
getUrlSignature,
|
||||||
|
hasValidSignature,
|
||||||
|
Snowflake,
|
||||||
|
} from "@spacebar/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
import FileType from "file-type";
|
import FileType from "file-type";
|
||||||
|
import imageSize from "image-size";
|
||||||
import { HTTPError } from "lambert-server";
|
import { HTTPError } from "lambert-server";
|
||||||
import { multer } from "../util/multer";
|
import { multer } from "../util/multer";
|
||||||
import imageSize from "image-size";
|
import { storage } from "../util/Storage";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -39,6 +44,7 @@ router.post(
|
|||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
if (req.headers.signature !== Config.get().security.requestSignature)
|
if (req.headers.signature !== Config.get().security.requestSignature)
|
||||||
throw new HTTPError("Invalid request signature");
|
throw new HTTPError("Invalid request signature");
|
||||||
|
|
||||||
if (!req.file) throw new HTTPError("file missing");
|
if (!req.file) throw new HTTPError("file missing");
|
||||||
|
|
||||||
const { buffer, mimetype, size, originalname } = req.file;
|
const { buffer, mimetype, size, originalname } = req.file;
|
||||||
@ -63,12 +69,20 @@ router.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let finalUrl = `${endpoint}/${path}`;
|
||||||
|
|
||||||
|
if (Config.get().security.cdnSignUrls) {
|
||||||
|
const signatureData = getUrlSignature(path);
|
||||||
|
console.log(signatureData);
|
||||||
|
finalUrl = `${finalUrl}?ex=${signatureData.expiresAt}&is=${signatureData.issuedAt}&hm=${signatureData.hash}&`;
|
||||||
|
}
|
||||||
|
|
||||||
const file = {
|
const file = {
|
||||||
id,
|
id,
|
||||||
content_type: mimetype,
|
content_type: mimetype,
|
||||||
filename: filename,
|
filename: filename,
|
||||||
size,
|
size,
|
||||||
url: `${endpoint}/${path}`,
|
url: finalUrl,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
};
|
};
|
||||||
@ -84,6 +98,14 @@ router.get(
|
|||||||
// const { format } = req.query;
|
// const { format } = req.query;
|
||||||
|
|
||||||
const path = `attachments/${channel_id}/${id}/${filename}`;
|
const path = `attachments/${channel_id}/${id}/${filename}`;
|
||||||
|
|
||||||
|
if (
|
||||||
|
Config.get().security.cdnSignUrls &&
|
||||||
|
!hasValidSignature(path, req.query)
|
||||||
|
) {
|
||||||
|
return res.status(404).send("This content is no longer available.");
|
||||||
|
}
|
||||||
|
|
||||||
const file = await storage.get(path);
|
const file = await storage.get(path);
|
||||||
if (!file) throw new HTTPError("File not found");
|
if (!file) throw new HTTPError("File not found");
|
||||||
const type = await FileType.fromBuffer(file);
|
const type = await FileType.fromBuffer(file);
|
||||||
|
136
src/util/Signing.ts
Normal file
136
src/util/Signing.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
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 { Config } from "@spacebar/util";
|
||||||
|
import { createHmac, timingSafeEqual } from "crypto";
|
||||||
|
import ms, { StringValue } from "ms";
|
||||||
|
import { ParsedQs } from "qs";
|
||||||
|
|
||||||
|
export const getUrlSignature = (path: string) => {
|
||||||
|
const { cdnSignatureKey, cdnSignatureDuration } = Config.get().security;
|
||||||
|
|
||||||
|
// calculate the expiration time
|
||||||
|
const now = Date.now();
|
||||||
|
const issuedAt = now.toString(16);
|
||||||
|
const expiresAt = (now + ms(cdnSignatureDuration as StringValue)).toString(
|
||||||
|
16,
|
||||||
|
);
|
||||||
|
|
||||||
|
// hash the url with the cdnSignatureKey
|
||||||
|
const hash = createHmac("sha256", cdnSignatureKey as string)
|
||||||
|
.update(path)
|
||||||
|
.update(issuedAt)
|
||||||
|
.update(expiresAt)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
issuedAt,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateHash = (
|
||||||
|
url: string,
|
||||||
|
issuedAt: string,
|
||||||
|
expiresAt: string,
|
||||||
|
) => {
|
||||||
|
const { cdnSignatureKey } = Config.get().security;
|
||||||
|
const hash = createHmac("sha256", cdnSignatureKey as string)
|
||||||
|
.update(url)
|
||||||
|
.update(issuedAt)
|
||||||
|
.update(expiresAt)
|
||||||
|
.digest("hex");
|
||||||
|
return hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isExpired = (ex: string, is: string) => {
|
||||||
|
// convert issued at
|
||||||
|
const issuedAt = parseInt(is, 16);
|
||||||
|
const expiresAt = parseInt(ex, 16);
|
||||||
|
|
||||||
|
if (Number.isNaN(issuedAt) || Number.isNaN(expiresAt)) {
|
||||||
|
// console.debug("Invalid timestamps in query");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTime = Date.now();
|
||||||
|
const isExpired = expiresAt < currentTime;
|
||||||
|
const isValidIssuedAt = issuedAt < currentTime;
|
||||||
|
if (isExpired || !isValidIssuedAt) {
|
||||||
|
// console.debug("Signature expired");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasValidSignature = (path: string, query: ParsedQs) => {
|
||||||
|
// get url path
|
||||||
|
const { ex, is, hm } = query;
|
||||||
|
|
||||||
|
// if the required query parameters are not present, return false
|
||||||
|
if (!ex || !is || !hm) return false;
|
||||||
|
|
||||||
|
// check if the signature is expired
|
||||||
|
if (isExpired(ex as string, is as string)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calcd = calculateHash(path, is as string, ex as string);
|
||||||
|
const calculated = Buffer.from(calcd);
|
||||||
|
const received = Buffer.from(hm as string);
|
||||||
|
|
||||||
|
const isHashValid =
|
||||||
|
calculated.length === received.length &&
|
||||||
|
timingSafeEqual(calculated, received);
|
||||||
|
// if (!isHashValid) {
|
||||||
|
// console.debug("Invalid signature");
|
||||||
|
// console.debug(calcd, hm);
|
||||||
|
// }
|
||||||
|
return isHashValid;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resignUrl = (attachmentUrl: string) => {
|
||||||
|
const url = new URL(attachmentUrl);
|
||||||
|
|
||||||
|
// if theres an existing signature, check if its expired or not. no reason to resign if its not expired
|
||||||
|
if (url.searchParams.has("ex") && url.searchParams.has("is")) {
|
||||||
|
// extract the ex and is
|
||||||
|
const ex = url.searchParams.get("ex");
|
||||||
|
const is = url.searchParams.get("is");
|
||||||
|
|
||||||
|
if (!isExpired(ex as string, is as string)) {
|
||||||
|
// if the signature is not expired, return the url as is
|
||||||
|
return attachmentUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = url.pathname;
|
||||||
|
// strip / from the start
|
||||||
|
if (path.startsWith("/")) {
|
||||||
|
path = path.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hash, issuedAt, expiresAt } = getUrlSignature(path);
|
||||||
|
url.searchParams.set("ex", expiresAt);
|
||||||
|
url.searchParams.set("is", issuedAt);
|
||||||
|
url.searchParams.set("hm", hash);
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
};
|
@ -37,4 +37,8 @@ export class SecurityConfiguration {
|
|||||||
mfaBackupCodeCount: number = 10;
|
mfaBackupCodeCount: number = 10;
|
||||||
statsWorldReadable: boolean = true;
|
statsWorldReadable: boolean = true;
|
||||||
defaultRegistrationTokenExpiration: number = 1000 * 60 * 60 * 24 * 7; //1 week
|
defaultRegistrationTokenExpiration: number = 1000 * 60 * 60 * 24 * 7; //1 week
|
||||||
|
// cdn signed urls
|
||||||
|
cdnSignUrls: boolean = false;
|
||||||
|
cdnSignatureKey: string = crypto.randomBytes(32).toString("base64");
|
||||||
|
cdnSignatureDuration: string = "24h";
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,8 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// NOTE: !! DO NOT REORDER THE IMPORTS !!
|
||||||
|
|
||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
|
|
||||||
export * from "./util/index";
|
export * from "./util/index";
|
||||||
@ -26,3 +28,4 @@ export * from "./schemas";
|
|||||||
export * from "./imports";
|
export * from "./imports";
|
||||||
export * from "./config";
|
export * from "./config";
|
||||||
export * from "./connections";
|
export * from "./connections";
|
||||||
|
export * from "./Signing"
|
21
src/util/schemas/RefreshUrlsRequestSchema.ts
Normal file
21
src/util/schemas/RefreshUrlsRequestSchema.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RefreshUrlsRequestSchema {
|
||||||
|
attachment_urls: string[];
|
||||||
|
}
|
@ -59,6 +59,7 @@ export * from "./MfaCodesSchema";
|
|||||||
export * from "./ModifyGuildStickerSchema";
|
export * from "./ModifyGuildStickerSchema";
|
||||||
export * from "./PasswordResetSchema";
|
export * from "./PasswordResetSchema";
|
||||||
export * from "./PurgeSchema";
|
export * from "./PurgeSchema";
|
||||||
|
export * from "./RefreshUrlsRequestSchema";
|
||||||
export * from "./RegisterSchema";
|
export * from "./RegisterSchema";
|
||||||
export * from "./RelationshipPostSchema";
|
export * from "./RelationshipPostSchema";
|
||||||
export * from "./RelationshipPutSchema";
|
export * from "./RelationshipPutSchema";
|
||||||
|
26
src/util/schemas/responses/RefreshUrlsResponse.ts
Normal file
26
src/util/schemas/responses/RefreshUrlsResponse.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RefreshedUrl {
|
||||||
|
original: string;
|
||||||
|
refreshed: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshUrlsResponse {
|
||||||
|
refreshed_urls: RefreshedUrl[];
|
||||||
|
}
|
@ -44,6 +44,7 @@ export * from "./InstanceStatsResponse";
|
|||||||
export * from "./LocationMetadataResponse";
|
export * from "./LocationMetadataResponse";
|
||||||
export * from "./MemberJoinGuildResponse";
|
export * from "./MemberJoinGuildResponse";
|
||||||
export * from "./OAuthAuthorizeResponse";
|
export * from "./OAuthAuthorizeResponse";
|
||||||
|
export * from "./RefreshUrlsResponse";
|
||||||
export * from "./TeamListResponse";
|
export * from "./TeamListResponse";
|
||||||
export * from "./Tenor";
|
export * from "./Tenor";
|
||||||
export * from "./TokenResponse";
|
export * from "./TokenResponse";
|
||||||
|
Loading…
x
Reference in New Issue
Block a user