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>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
concurrency:
|
||||
group: publish-docker-image-${{ github.event.release.tag_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BUILD_ARGS: |
|
||||
NEXT_PUBLIC_APP_PLATFORM=web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Detect Docker Hub credentials
|
||||
id: dockerhub
|
||||
run: |
|
||||
if [ -n "${{ secrets.DOCKERHUB_USERNAME }}" ] && [ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.dockerhub.outputs.enabled == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata (GHCR only)
|
||||
id: meta-ghcr
|
||||
if: steps.dockerhub.outputs.enabled != 'true'
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/readest
|
||||
tags: |
|
||||
type=raw,value=main,enable={{is_default_branch}}
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||
type=raw,value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' }}
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Extract image metadata (GHCR + Docker Hub)
|
||||
id: meta-all
|
||||
if: steps.dockerhub.outputs.enabled == 'true'
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository_owner }}/readest
|
||||
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/readest
|
||||
tags: |
|
||||
type=raw,value=main,enable={{is_default_branch}}
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||
type=raw,value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' }}
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Build and push (GHCR only)
|
||||
id: build-ghcr
|
||||
if: steps.dockerhub.outputs.enabled != 'true'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: production-stage
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta-ghcr.outputs.tags }}
|
||||
labels: ${{ steps.meta-ghcr.outputs.labels }}
|
||||
build-args: ${{ env.BUILD_ARGS }}
|
||||
|
||||
- name: Published image summary (GHCR only)
|
||||
if: steps.dockerhub.outputs.enabled != 'true'
|
||||
run: |
|
||||
echo "## Published Images" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Registry: GHCR" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Tags:" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "${{ steps.meta-ghcr.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Digest: \`${{ steps.build-ghcr.outputs.digest }}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Build and push (GHCR + Docker Hub)
|
||||
id: build-all
|
||||
if: steps.dockerhub.outputs.enabled == 'true'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: production-stage
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta-all.outputs.tags }}
|
||||
labels: ${{ steps.meta-all.outputs.labels }}
|
||||
build-args: ${{ env.BUILD_ARGS }}
|
||||
|
||||
- name: Published image summary (GHCR + Docker Hub)
|
||||
if: steps.dockerhub.outputs.enabled == 'true'
|
||||
run: |
|
||||
echo "## Published Images" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Registries: GHCR, Docker Hub" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Tags:" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "${{ steps.meta-all.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Digest: \`${{ steps.build-all.outputs.digest }}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -19,6 +19,7 @@ beforeEach(() => {
|
||||
Object.assign(env, originalEnv);
|
||||
// Clean up any window globals we set
|
||||
delete (window as unknown as Record<string, unknown>)['__READEST_CLI_ACCESS'];
|
||||
delete (window as unknown as Record<string, unknown>)['__READEST_RUNTIME_CONFIG'];
|
||||
});
|
||||
|
||||
describe('environment', () => {
|
||||
@@ -106,6 +107,22 @@ describe('environment', () => {
|
||||
|
||||
// ── getBaseUrl ─────────────────────────────────────────────────
|
||||
describe('getBaseUrl', () => {
|
||||
test('returns runtime-configured apiBaseUrl when set', async () => {
|
||||
window.__READEST_RUNTIME_CONFIG = {
|
||||
apiBaseUrl: 'https://runtime-api.example.com',
|
||||
};
|
||||
env['NEXT_PUBLIC_API_BASE_URL'] = 'https://custom-api.example.com';
|
||||
const { getBaseUrl } = await import('@/services/environment');
|
||||
expect(getBaseUrl()).toBe('https://runtime-api.example.com');
|
||||
});
|
||||
|
||||
test('returns API_BASE_URL when set', async () => {
|
||||
env['API_BASE_URL'] = 'https://runtime-api.example.com';
|
||||
delete env['NEXT_PUBLIC_API_BASE_URL'];
|
||||
const { getBaseUrl } = await import('@/services/environment');
|
||||
expect(getBaseUrl()).toBe('https://runtime-api.example.com');
|
||||
});
|
||||
|
||||
test('returns NEXT_PUBLIC_API_BASE_URL when set', async () => {
|
||||
env['NEXT_PUBLIC_API_BASE_URL'] = 'https://custom-api.example.com';
|
||||
const { getBaseUrl } = await import('@/services/environment');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import Script from 'next/script';
|
||||
import { ViewTransitions } from 'next-view-transitions';
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import Providers from '@/components/Providers';
|
||||
@@ -128,11 +129,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
lang='en'
|
||||
className={process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'tauri' ? 'edge-to-edge' : ''}
|
||||
>
|
||||
{shouldInjectDevHmrPatch ? (
|
||||
<head>
|
||||
<Script src='/runtime-config.js' strategy='beforeInteractive' />
|
||||
{shouldInjectDevHmrPatch ? (
|
||||
<script dangerouslySetInnerHTML={{ __html: devHmrPatchScript }} />
|
||||
</head>
|
||||
) : null}
|
||||
</head>
|
||||
<body>
|
||||
<ViewTransitions>
|
||||
<EnvProvider>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerRuntimeConfig } from '@/services/runtimeConfig';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export function GET() {
|
||||
const serializedConfig = JSON.stringify(getServerRuntimeConfig())
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/\u2028/g, '\\u2028')
|
||||
.replace(/\u2029/g, '\\u2029');
|
||||
const script = `window.__READEST_RUNTIME_CONFIG=${serializedConfig};`;
|
||||
return new NextResponse(script, {
|
||||
headers: {
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'no-store, max-age=0',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Html, Head, Main, NextScript } from 'next/document';
|
||||
import Script from 'next/script';
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang='en'>
|
||||
<Head />
|
||||
<body>
|
||||
{/* beforeInteractive must live in _document (not _app) to guarantee the
|
||||
script runs before any client-side module evaluation. */}
|
||||
<Script src='/runtime-config.js' strategy='beforeInteractive' />
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppService } from '@/types/system';
|
||||
import { READEST_NODE_BASE_URL, READEST_WEB_BASE_URL } from './constants';
|
||||
import { getRuntimeConfig } from './runtimeConfig';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -11,7 +12,11 @@ export const isTauriAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM']
|
||||
export const isWebAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
|
||||
export const hasCli = () => window.__READEST_CLI_ACCESS === true;
|
||||
export const isPWA = () => window.matchMedia('(display-mode: standalone)').matches;
|
||||
export const getBaseUrl = () => process.env['NEXT_PUBLIC_API_BASE_URL'] ?? READEST_WEB_BASE_URL;
|
||||
export const getBaseUrl = () =>
|
||||
getRuntimeConfig()?.apiBaseUrl ??
|
||||
process.env['API_BASE_URL'] ??
|
||||
process.env['NEXT_PUBLIC_API_BASE_URL'] ??
|
||||
READEST_WEB_BASE_URL;
|
||||
export const getNodeBaseUrl = () =>
|
||||
process.env['NEXT_PUBLIC_NODE_BASE_URL'] ?? READEST_NODE_BASE_URL;
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export interface ReadestRuntimeConfig {
|
||||
supabaseUrl?: string;
|
||||
supabaseAnonKey?: string;
|
||||
apiBaseUrl?: string;
|
||||
objectStorageType?: string;
|
||||
storageFixedQuota?: number;
|
||||
translationFixedQuota?: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__READEST_RUNTIME_CONFIG?: ReadestRuntimeConfig;
|
||||
}
|
||||
}
|
||||
|
||||
export const getRuntimeConfig = () =>
|
||||
typeof window === 'undefined' ? undefined : window.__READEST_RUNTIME_CONFIG;
|
||||
|
||||
export const getServerRuntimeConfig = (): ReadestRuntimeConfig => ({
|
||||
// Browser runtime config should prefer a public Supabase URL when provided.
|
||||
// SUPABASE_URL remains as a backward-compatible fallback for non-split setups.
|
||||
supabaseUrl:
|
||||
process.env['SUPABASE_PUBLIC_URL'] ??
|
||||
process.env['NEXT_PUBLIC_SUPABASE_URL'] ??
|
||||
process.env['SUPABASE_URL'],
|
||||
supabaseAnonKey: process.env['SUPABASE_ANON_KEY'] ?? process.env['NEXT_PUBLIC_SUPABASE_ANON_KEY'],
|
||||
apiBaseUrl:
|
||||
process.env['API_BASE_URL'] ??
|
||||
process.env['NEXT_PUBLIC_API_BASE_URL'] ??
|
||||
process.env['SITE_URL'],
|
||||
// These were previously baked as NEXT_PUBLIC_* build args; now read from runtime env so
|
||||
// the published image can be configured without rebuilding.
|
||||
objectStorageType:
|
||||
process.env['OBJECT_STORAGE_TYPE'] ?? process.env['NEXT_PUBLIC_OBJECT_STORAGE_TYPE'],
|
||||
storageFixedQuota: (() => {
|
||||
const raw =
|
||||
process.env['STORAGE_FIXED_QUOTA'] ?? process.env['NEXT_PUBLIC_STORAGE_FIXED_QUOTA'];
|
||||
return raw ? parseInt(raw, 10) : undefined;
|
||||
})(),
|
||||
translationFixedQuota: (() => {
|
||||
const raw =
|
||||
process.env['TRANSLATION_FIXED_QUOTA'] ?? process.env['NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA'];
|
||||
return raw ? parseInt(raw, 10) : undefined;
|
||||
})(),
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { UserPlan } from '@/types/quota';
|
||||
import { DEFAULT_DAILY_TRANSLATION_QUOTA, DEFAULT_STORAGE_QUOTA } from '@/services/constants';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { getDailyUsage } from '@/services/translators/utils';
|
||||
import { getRuntimeConfig } from '@/services/runtimeConfig';
|
||||
|
||||
interface Token {
|
||||
plan: UserPlan;
|
||||
@@ -36,7 +37,9 @@ export const getStoragePlanData = (token: string) => {
|
||||
const plan = data['plan'] || 'free';
|
||||
const usage = data['storage_usage_bytes'] || 0;
|
||||
const purchasedQuota = data['storage_purchased_bytes'] || 0;
|
||||
const fixedQuota = parseInt(process.env['NEXT_PUBLIC_STORAGE_FIXED_QUOTA'] || '0');
|
||||
const runtimeConfig = getRuntimeConfig();
|
||||
const fixedQuota =
|
||||
runtimeConfig?.storageFixedQuota ?? parseInt(process.env['STORAGE_FIXED_QUOTA'] ?? '0');
|
||||
const planQuota = fixedQuota || DEFAULT_STORAGE_QUOTA[plan] || DEFAULT_STORAGE_QUOTA['free'];
|
||||
const quota = planQuota + purchasedQuota;
|
||||
|
||||
@@ -48,7 +51,9 @@ export const getStoragePlanData = (token: string) => {
|
||||
};
|
||||
|
||||
export const getTranslationQuota = (plan: UserPlan): number => {
|
||||
const fixedQuota = parseInt(process.env['NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA'] || '0');
|
||||
const runtimeConfig = getRuntimeConfig();
|
||||
const fixedQuota =
|
||||
runtimeConfig?.translationFixedQuota ?? parseInt(process.env['TRANSLATION_FIXED_QUOTA'] ?? '0');
|
||||
return (
|
||||
fixedQuota || DEFAULT_DAILY_TRANSLATION_QUOTA[plan] || DEFAULT_DAILY_TRANSLATION_QUOTA['free']
|
||||
);
|
||||
|
||||
@@ -9,18 +9,34 @@ import {
|
||||
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: {
|
||||
accessKeyId: S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: S3_SECRET_ACCESS_KEY,
|
||||
},
|
||||
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 = {
|
||||
@@ -29,10 +45,7 @@ export const s3Storage = {
|
||||
forcePathStyle: true,
|
||||
region: S3_REGION,
|
||||
endpoint: S3_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: S3_SECRET_ACCESS_KEY,
|
||||
},
|
||||
credentials: s3ClientCredentials,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -41,7 +54,7 @@ export const s3Storage = {
|
||||
Bucket: bucketName,
|
||||
Key: fileKey,
|
||||
});
|
||||
const downloadUrl = await getSignedUrl(s3Client, getCommand, {
|
||||
const downloadUrl = await getSignedUrl(s3SigningClient, getCommand, {
|
||||
expiresIn: expiresIn,
|
||||
});
|
||||
return downloadUrl;
|
||||
@@ -61,7 +74,7 @@ export const s3Storage = {
|
||||
ContentLength: contentLength,
|
||||
});
|
||||
|
||||
const uploadUrl = await getSignedUrl(s3Client, putCommand, {
|
||||
const uploadUrl = await getSignedUrl(s3SigningClient, putCommand, {
|
||||
expiresIn: expiresIn,
|
||||
signableHeaders,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { getRuntimeConfig } from '@/services/runtimeConfig';
|
||||
|
||||
type ObjectStorageType = 'r2' | 's3';
|
||||
|
||||
export const getStorageType = (): ObjectStorageType => {
|
||||
// TODO: do not expose storage type to client
|
||||
if (process.env['NEXT_PUBLIC_OBJECT_STORAGE_TYPE']) {
|
||||
return process.env['NEXT_PUBLIC_OBJECT_STORAGE_TYPE'] as ObjectStorageType;
|
||||
} else {
|
||||
return 'r2';
|
||||
}
|
||||
// Client: read from runtime config injected via /runtime-config.js at container start.
|
||||
// Server: fall back to the OBJECT_STORAGE_TYPE process env var.
|
||||
const runtimeType = getRuntimeConfig()?.objectStorageType ?? process.env['OBJECT_STORAGE_TYPE'];
|
||||
return (runtimeType as ObjectStorageType) || 'r2';
|
||||
};
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { getRuntimeConfig } from '@/services/runtimeConfig';
|
||||
|
||||
const supabaseUrl =
|
||||
getRuntimeConfig()?.supabaseUrl ||
|
||||
process.env['SUPABASE_URL'] ||
|
||||
process.env['NEXT_PUBLIC_SUPABASE_URL'] ||
|
||||
atob(process.env['NEXT_PUBLIC_DEFAULT_SUPABASE_URL_BASE64']!);
|
||||
const supabaseAnonKey =
|
||||
getRuntimeConfig()?.supabaseAnonKey ||
|
||||
process.env['SUPABASE_ANON_KEY'] ||
|
||||
process.env['NEXT_PUBLIC_SUPABASE_ANON_KEY'] ||
|
||||
atob(process.env['NEXT_PUBLIC_DEFAULT_SUPABASE_KEY_BASE64']!);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# ip used by frontend/client-side urls to access the backend
|
||||
# (used in compose for SUPABASE_PUBLIC_URL)
|
||||
HOST_IP=localhost
|
||||
READEST_IMAGE=ghcr.io/readest/readest:latest
|
||||
|
||||
# db(psql) config
|
||||
# change to strong password, min 32 chars
|
||||
@@ -42,6 +44,7 @@ SMTP_SENDER_NAME=Readest
|
||||
PGRST_DB_SCHEMAS=public,graphql_public
|
||||
|
||||
# s3 storage through MinIO
|
||||
OBJECT_STORAGE_TYPE=s3
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin-secret-password
|
||||
S3_BUCKET_NAME=readest-files
|
||||
|
||||
+42
-16
@@ -4,7 +4,7 @@
|
||||
|
||||
| service | Image | Description |
|
||||
| --------------- | --------------------------- | ------------------------------------------------- |
|
||||
| **client** | from `../Dockerfile` | readest frontend |
|
||||
| **client** | `ghcr.io/readest/readest` | readest frontend |
|
||||
| **db** | `supabase/postgres` | psql db with supabase extensions |
|
||||
| **kong** | `kong:2.8.1` | api gateway routing requests to supabase services |
|
||||
| **auth** | `supabase/gotrue:v2.185.0` | auth service (email, JWT) |
|
||||
@@ -17,7 +17,7 @@
|
||||
| Port | Service |
|
||||
| ------ | ---------------- |
|
||||
| `3000` | readest |
|
||||
| `7000` | kong API gateway |
|
||||
| `8000` | kong API gateway |
|
||||
| `9000` | MinIO S3 API |
|
||||
| `9001` | MinIO console UI |
|
||||
|
||||
@@ -40,16 +40,40 @@ update `docker/.env`:
|
||||
- `SERVICE_ROLE_KEY` payload: `{"role": "service_role"}`
|
||||
- set `MINIO_ROOT_PASSWORD` to a strong password
|
||||
|
||||
### 2. Start the Stack
|
||||
### 2. Start the Stack (pull prebuilt client image)
|
||||
|
||||
run from the `docker/` directory:
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
docker compose up --build -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
the client image is built locally on first run. subsequent starts reuse the cached image.
|
||||
this pulls `${READEST_IMAGE}` (default: `ghcr.io/readest/readest:latest`) instead of building the client locally.
|
||||
the web client now reads `SUPABASE_PUBLIC_URL`, `SUPABASE_ANON_KEY`, `API_BASE_URL`, `OBJECT_STORAGE_TYPE`, `STORAGE_FIXED_QUOTA`, and `TRANSLATION_FIXED_QUOTA` from runtime
|
||||
container env, so custom self-hosted values work with pulled images.
|
||||
|
||||
if you prefer Docker Hub, set `READEST_IMAGE` in `docker/.env`, for example:
|
||||
|
||||
```env
|
||||
READEST_IMAGE=docker.io/your-dockerhub-username/readest:latest
|
||||
```
|
||||
|
||||
replace `your-dockerhub-username` with the Docker Hub namespace that publishes your `readest` image.
|
||||
for official images, use the namespace configured for this repository's Docker Hub publishing secrets.
|
||||
|
||||
published tags:
|
||||
- `latest`: published from release events
|
||||
- `<release-tag>` (for example `v1.2.3`): published from release events
|
||||
- `main`: rolling image from the default branch
|
||||
- `sha-<commit>`: immutable commit tag
|
||||
|
||||
### Build locally instead of pulling
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
docker compose -f compose.yaml -f compose.build.yaml up --build -d
|
||||
```
|
||||
|
||||
### 3. Access
|
||||
|
||||
@@ -58,7 +82,7 @@ the client image is built locally on first run. subsequent starts reuse the cach
|
||||
|
||||
### Hot Reload (development)
|
||||
|
||||
to develop using the compose stack, set the build target on `client` to `development-stage`, which'll runs the next.js dev server. to enable hot reload, uncomment the `volumes` block in the `client` service in `compose.yaml`:
|
||||
to develop using the compose stack, use local builds (`compose.yaml` + `compose.build.yaml`) and set the build target on `client` to `development-stage` in `compose.build.yaml`, which runs the next.js dev server. to enable hot reload, uncomment the `volumes` block in the `client` service in `compose.yaml`:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
@@ -90,18 +114,10 @@ docker compose down -v
|
||||
|
||||
## Building the Dockerfile standalone
|
||||
|
||||
the `Dockerfile` requires Build args for the next.js public env vars (they are inlined at build time)
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--target production-stage \
|
||||
--build-arg NEXT_PUBLIC_SUPABASE_URL=http://localhost:7000 \
|
||||
--build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key> \
|
||||
--build-arg NEXT_PUBLIC_APP_PLATFORM=web \
|
||||
--build-arg NEXT_PUBLIC_API_BASE_URL=http://localhost:3000 \
|
||||
--build-arg NEXT_PUBLIC_OBJECT_STORAGE_TYPE=s3 \
|
||||
--build-arg NEXT_PUBLIC_STORAGE_FIXED_QUOTA=1073741824 \
|
||||
--build-arg NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA=50000 \
|
||||
-t readest-client \
|
||||
.
|
||||
```
|
||||
@@ -110,13 +126,23 @@ run the built image:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 \
|
||||
-e SUPABASE_URL=http://kong:8000 \
|
||||
-e SUPABASE_URL=http://host.docker.internal:8000 \
|
||||
-e SUPABASE_PUBLIC_URL=http://localhost:8000 \
|
||||
-e SUPABASE_ANON_KEY=<anon-key> \
|
||||
-e SUPABASE_ADMIN_KEY=<service-role-key> \
|
||||
-e S3_ENDPOINT=http://localhost:9000 \
|
||||
-e API_BASE_URL=http://localhost:3000 \
|
||||
-e OBJECT_STORAGE_TYPE=s3 \
|
||||
-e S3_ENDPOINT=http://host.docker.internal:9000 \
|
||||
-e S3_PUBLIC_ENDPOINT=http://localhost:9000 \
|
||||
-e S3_REGION=us-east-1 \
|
||||
-e S3_BUCKET_NAME=readest-files \
|
||||
-e S3_ACCESS_KEY_ID=<minio-user> \
|
||||
-e S3_SECRET_ACCESS_KEY=<minio-password> \
|
||||
-e STORAGE_FIXED_QUOTA=1073741824 \
|
||||
-e TRANSLATION_FIXED_QUOTA=50000 \
|
||||
readest-client
|
||||
```
|
||||
|
||||
on Linux, some Docker setups do not resolve `host.docker.internal` by default.
|
||||
in that case, either replace it with your host IP or run with:
|
||||
`--add-host=host.docker.internal:host-gateway`.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
client:
|
||||
build:
|
||||
context: ..
|
||||
target: production-stage
|
||||
args:
|
||||
NEXT_PUBLIC_SUPABASE_URL: http://${HOST_IP}:${KONG_HTTP_PORT:-8000}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
NEXT_PUBLIC_APP_PLATFORM: web
|
||||
NEXT_PUBLIC_API_BASE_URL: http://${HOST_IP}:3000
|
||||
+9
-12
@@ -119,17 +119,8 @@ services:
|
||||
|
||||
client:
|
||||
container_name: readest-client
|
||||
build:
|
||||
context: ..
|
||||
target: production-stage
|
||||
args:
|
||||
NEXT_PUBLIC_SUPABASE_URL: http://${HOST_IP}:${KONG_HTTP_PORT:-8000}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
NEXT_PUBLIC_APP_PLATFORM: web
|
||||
NEXT_PUBLIC_API_BASE_URL: http://${HOST_IP}:3000
|
||||
NEXT_PUBLIC_OBJECT_STORAGE_TYPE: s3
|
||||
NEXT_PUBLIC_STORAGE_FIXED_QUOTA: ${STORAGE_FIXED_QUOTA}
|
||||
NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA: ${TRANSLATION_FIXED_QUOTA}
|
||||
image: ${READEST_IMAGE:-ghcr.io/readest/readest:latest}
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -143,13 +134,19 @@ services:
|
||||
# - /app/packages/foliate-js/node_modules
|
||||
environment:
|
||||
SUPABASE_URL: http://kong:8000
|
||||
SUPABASE_PUBLIC_URL: http://${HOST_IP}:${KONG_HTTP_PORT:-8000}
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_ADMIN_KEY: ${SERVICE_ROLE_KEY}
|
||||
S3_ENDPOINT: http://${HOST_IP}:9000
|
||||
API_BASE_URL: ${SITE_URL}
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
S3_PUBLIC_ENDPOINT: http://${HOST_IP}:9000
|
||||
S3_REGION: us-east-1
|
||||
S3_BUCKET_NAME: ${S3_BUCKET_NAME}
|
||||
S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
|
||||
S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
|
||||
OBJECT_STORAGE_TYPE: ${OBJECT_STORAGE_TYPE:-s3}
|
||||
STORAGE_FIXED_QUOTA: ${STORAGE_FIXED_QUOTA:-1073741824}
|
||||
TRANSLATION_FIXED_QUOTA: ${TRANSLATION_FIXED_QUOTA:-50000}
|
||||
depends_on:
|
||||
- kong
|
||||
- minio
|
||||
|
||||
Reference in New Issue
Block a user