Files
readest/apps/readest-app/src/utils/s3.ts
T
Amir Pourmand 9ad43aa8b2 feat(docker): add GHCR and Docker Hub image publishing (#4250)
* Add GHCR and Docker Hub image publishing with fully runtime-configurable pull-first Docker setup (#1)

* feat: add container image publishing workflow and pull-based compose setup

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/23c31167-9e15-4d44-ab89-f267b8cd6304

* refine docker publishing workflow and pull-first compose docs

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/23c31167-9e15-4d44-ab89-f267b8cd6304

* chore: temporarily expose docker publish run results for pr branch

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/c946a2f2-2219-4dea-a829-61b287bc4859

* fix: update pinned SHAs for setup-qemu-action and setup-buildx-action to v3 heads

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/0db6957e-476b-48e0-acf3-bee6964c3b32

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* chore: switch all workflow action refs from SHA pins to stable version tags

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/19334b37-9b4c-45df-9c3c-81a497cef8e8

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: restore missing `with:` blocks lost during SHA-to-tag substitution

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/f204f742-5b7d-4f05-9647-03b9db86ea3d

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix(ci): checkout submodules for docker image workflow

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/72309e8a-6c7c-4004-902a-565f67e5c15f

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* feat(docker): support runtime client env for pulled web image

* refactor(web): serve runtime config via script endpoint

* fix(web): escape runtime config script payload

* fix: align docker runtime config with internal/public backend urls

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/3bbcb608-6202-4f9e-b288-5c95a259da93

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* chore: align published workflow tags with docs

* docs: clarify runtime config precedence and linux host mapping

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/3bbcb608-6202-4f9e-b288-5c95a259da93

* refactor: move storage/quota config from build args to runtime env

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/da9b749e-0b1c-47b9-b474-5009765b6ea6

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: load runtime config in pages router app shell

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/b375132c-8317-4c98-b437-ae48c0153e3d

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: apply biome formatting for runtime config quota helpers

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/778f5d75-884e-401b-b7cb-4ab40bc64a11

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>
Co-authored-by: Amir Pourmand <pourmand1376@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: S3_PUBLIC_ENDPOINT, _document.tsx for beforeInteractive, runtimeConfig fallbacks, README port (#2)

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/4f92f818-c008-4caa-9684-d530500b5fb2

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: reformat runtimeConfig.ts to satisfy biome formatter (#3)

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/4eea77f4-67ab-4428-b59e-0b21be988037

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-21 19:57:35 +02:00

139 lines
4.1 KiB
TypeScript

import { S3Client } from '@aws-sdk/client-s3';
import {
GetObjectCommand,
DeleteObjectCommand,
PutObjectCommand,
HeadObjectCommand,
CopyObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const S3_ENDPOINT = process.env['S3_ENDPOINT'] || '';
// S3_PUBLIC_ENDPOINT is the MinIO URL reachable by browsers (e.g. http://<host-ip>:9000).
// When unset it falls back to S3_ENDPOINT so single-endpoint setups are unaffected.
const S3_PUBLIC_ENDPOINT = process.env['S3_PUBLIC_ENDPOINT'] || S3_ENDPOINT;
const S3_REGION = process.env['S3_REGION'] || 'auto';
const S3_ACCESS_KEY_ID = process.env['S3_ACCESS_KEY_ID'] || '';
const S3_SECRET_ACCESS_KEY = process.env['S3_SECRET_ACCESS_KEY'] || '';
const s3ClientCredentials = {
accessKeyId: S3_ACCESS_KEY_ID,
secretAccessKey: S3_SECRET_ACCESS_KEY,
};
// Internal client used for server-side SDK calls (PutObject, CopyObject, etc.)
export const s3Client = new S3Client({
forcePathStyle: true,
region: S3_REGION,
endpoint: S3_ENDPOINT,
credentials: s3ClientCredentials,
});
// Signing client uses S3_PUBLIC_ENDPOINT so presigned URLs contain a hostname
// that browsers can reach (S3_ENDPOINT may be an internal docker hostname like
// "minio:9000" which is not resolvable outside the docker network).
const s3SigningClient = new S3Client({
forcePathStyle: true,
region: S3_REGION,
endpoint: S3_PUBLIC_ENDPOINT,
credentials: s3ClientCredentials,
});
export const s3Storage = {
getClient: () => {
return new S3Client({
forcePathStyle: true,
region: S3_REGION,
endpoint: S3_ENDPOINT,
credentials: s3ClientCredentials,
});
},
getDownloadSignedUrl: async (bucketName: string, fileKey: string, expiresIn: number) => {
const getCommand = new GetObjectCommand({
Bucket: bucketName,
Key: fileKey,
});
const downloadUrl = await getSignedUrl(s3SigningClient, getCommand, {
expiresIn: expiresIn,
});
return downloadUrl;
},
getUploadSignedUrl: async (
bucketName: string,
fileKey: string,
contentLength: number,
expiresIn: number,
) => {
const signableHeaders = new Set<string>();
signableHeaders.add('content-length');
const putCommand = new PutObjectCommand({
Bucket: bucketName,
Key: fileKey,
ContentLength: contentLength,
});
const uploadUrl = await getSignedUrl(s3SigningClient, putCommand, {
expiresIn: expiresIn,
signableHeaders,
});
return uploadUrl;
},
putObject: async (
bucketName: string,
fileKey: string,
body: ArrayBuffer | string,
contentType: string,
) => {
const putCommand = new PutObjectCommand({
Bucket: bucketName,
Key: fileKey,
Body: body instanceof ArrayBuffer ? new Uint8Array(body) : body,
ContentType: contentType,
});
return await s3Storage.getClient().send(putCommand);
},
deleteObject: async (bucketName: string, fileKey: string) => {
const deleteCommand = new DeleteObjectCommand({
Bucket: bucketName,
Key: fileKey,
});
return await s3Storage.getClient().send(deleteCommand);
},
headObject: async (bucketName: string, fileKey: string) => {
const headCommand = new HeadObjectCommand({
Bucket: bucketName,
Key: fileKey,
});
return await s3Storage.getClient().send(headCommand);
},
copyObject: async (
bucketName: string,
sourceFileKey: string,
destFileKey: string,
sourceBucketName?: string,
) => {
const srcBucket = sourceBucketName || bucketName;
// S3 requires CopySource to be URL-encoded segment-by-segment. file_key
// is built from the original filename, so spaces and reserved chars
// (e.g. `My Book.epub`, `A&B.epub`) are common and would otherwise
// break the copy.
const encodeKey = (key: string): string => key.split('/').map(encodeURIComponent).join('/');
const copyCommand = new CopyObjectCommand({
Bucket: bucketName,
Key: destFileKey,
CopySource: `${srcBucket}/${encodeKey(sourceFileKey)}`,
});
return await s3Storage.getClient().send(copyCommand);
},
};