diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 063f16e..0a36aa1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,12 +23,19 @@ jobs: repos: - image: streetwriters/notesnook-sync file: ./Notesnook.API/Dockerfile + context: . + + - image: streetwriters/cors-proxy + file: ./cors-proxy/Dockerfile + context: ./cors-proxy/ - image: streetwriters/identity file: ./Streetwriters.Identity/Dockerfile + context: . - image: streetwriters/sse file: ./Streetwriters.Messenger/Dockerfile + context: . permissions: packages: write contents: read @@ -42,7 +49,7 @@ jobs: - name: Docker Setup Buildx uses: docker/setup-buildx-action@v3 with: - platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8 + platforms: linux/amd64,linux/arm64 - name: Log in to Docker Hub uses: docker/login-action@v3 @@ -71,10 +78,10 @@ jobs: id: push uses: docker/build-push-action@v6 with: - context: . + context: ${{ matrix.repos.context }} file: ${{ matrix.repos.file }} push: true - platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8 + platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} cache-from: ${{ matrix.repos.image }}:latest diff --git a/cors-proxy/Dockerfile b/cors-proxy/Dockerfile new file mode 100644 index 0000000..6b92081 --- /dev/null +++ b/cors-proxy/Dockerfile @@ -0,0 +1,19 @@ +FROM oven/bun:1.3.5-slim + +RUN mkdir -p /home/bun/app && chown -R bun:bun /home/bun/app + +WORKDIR /home/bun/app + +USER bun + +COPY --chown=bun:bun package.json bun.lock . + +RUN bun install --frozen-lockfile + +COPY --chown=bun:bun . . + +RUN bun run build + +EXPOSE 3000 + +CMD ["bun", "run", "start"] diff --git a/cors-proxy/bun.lock b/cors-proxy/bun.lock new file mode 100644 index 0000000..241c3cc --- /dev/null +++ b/cors-proxy/bun.lock @@ -0,0 +1,18 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "cors-proxy", + "devDependencies": { + "bun-types": "latest", + }, + }, + }, + "packages": { + "@types/node": ["@types/node@24.10.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA=="], + + "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/cors-proxy/package.json b/cors-proxy/package.json new file mode 100644 index 0000000..5094855 --- /dev/null +++ b/cors-proxy/package.json @@ -0,0 +1,23 @@ +{ + "name": "@streetwriters/cors-proxy", + "version": "1.0.0", + "description": "Production-ready CORS proxy server built with Bun", + "main": "src/index.ts", + "type": "module", + "scripts": { + "build": "bun build src/index.ts --outdir dist --target bun", + "start": "bun run dist/index.js", + "dev": "bun --watch src/index.ts" + }, + "keywords": [ + "cors", + "proxy", + "bun", + "server" + ], + "author": "", + "license": "MIT", + "devDependencies": { + "bun-types": "latest" + } +} diff --git a/cors-proxy/src/index.ts b/cors-proxy/src/index.ts new file mode 100644 index 0000000..7eeb933 --- /dev/null +++ b/cors-proxy/src/index.ts @@ -0,0 +1,227 @@ +/** + * Production-ready CORS Proxy Server + * Built with Bun runtime + */ + +const PORT = Bun.env.PORT || 3000; +const ALLOWED_ORIGINS = Bun.env.ALLOWED_ORIGINS?.split(",") || ["*"]; +const MAX_REDIRECTS = 5; + +// CORS headers configuration +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": + "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH", + "Access-Control-Allow-Headers": + "Content-Type, Authorization, X-Requested-With, Accept, Accept-Language, Referer, Origin", + "Access-Control-Max-Age": "86400", + "Access-Control-Expose-Headers": + "Content-Length, Content-Type, Date, Server, X-Powered-By", +}; + +// Log request for monitoring +function logRequest(method: string, url: string, status: number) { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] ${method} ${url} - ${status}`); +} + +// Validate URL +function isValidUrl(urlString: string): boolean { + try { + const url = new URL(urlString); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +// Handle proxied request with redirect support +async function proxyRequest( + targetUrl: string, + redirectCount = 0 +): Promise { + if (redirectCount >= MAX_REDIRECTS) { + return new Response("Too many redirects", { + status: 508, + headers: corsHeaders, + }); + } + + try { + const response = await fetch(targetUrl, { + method: "GET", + redirect: "manual", + }); + + // Handle redirects manually + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("Location"); + if (location) { + const redirectUrl = new URL(location, targetUrl).toString(); + return proxyRequest(redirectUrl, redirectCount + 1); + } + } + + // Get response headers + const responseHeaders = new Headers(corsHeaders); + + // Forward important headers (but NOT content-encoding since fetch auto-decompresses) + const headersToForward = [ + "content-type", + "content-length", + "cache-control", + "etag", + "last-modified", + "content-disposition", + "content-range", + "accept-ranges", + "vary", + "date", + "expires", + "age", + ]; + + headersToForward.forEach((header) => { + const value = response.headers.get(header); + if (value) { + responseHeaders.set(header, value); + } + }); + + // Remove headers that might reveal proxy usage + responseHeaders.delete("x-powered-by"); + responseHeaders.delete("server"); + responseHeaders.delete("via"); + responseHeaders.delete("x-proxy"); + responseHeaders.delete("x-cache"); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + console.error(`Proxy error: ${errorMessage}`); + return new Response(`Proxy error: ${errorMessage}`, { + status: 502, + headers: corsHeaders, + }); + } +} + +// Main server +const server = Bun.serve({ + port: PORT, + async fetch(req) { + const url = new URL(req.url); + + // Health check endpoint + if (url.pathname === "/health") { + logRequest(req.method, url.pathname, 200); + return new Response("OK", { + status: 200, + headers: corsHeaders, + }); + } + + // Handle CORS preflight + if (req.method === "OPTIONS") { + logRequest(req.method, url.pathname, 204); + return new Response(null, { + status: 204, + headers: corsHeaders, + }); + } + + // Root endpoint with usage info + if (url.pathname === "/") { + const usage = { + service: "CORS Proxy Server", + version: "1.0.0", + usage: { + method1: "GET /", + method2: "GET /?url=", + example1: `${url.origin}/https://example.com/image.jpg`, + example2: `${url.origin}/?url=${encodeURIComponent( + "https://example.com/image.jpg" + )}`, + }, + endpoints: { + health: "/health", + proxy: "/ or /?url=", + }, + }; + + logRequest(req.method, url.pathname, 200); + return new Response(JSON.stringify(usage, null, 2), { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); + } + + // Get target URL from path or query parameter + let targetUrl: string | null = null; + + // Method 1: Direct path (preferred) - http://localhost:3000/https://example.com/image.jpg + if (url.pathname !== "/" && url.pathname !== "/health") { + // Remove leading slash and reconstruct the full URL with query string + targetUrl = url.pathname.slice(1); + if (url.search) { + targetUrl += url.search; + } + } + + // Method 2: Query parameter - http://localhost:3000/?url=https://example.com/image.jpg + if (!targetUrl) { + targetUrl = url.searchParams.get("url"); + } + + if (!targetUrl) { + logRequest(req.method, url.pathname, 400); + return new Response( + "Missing URL. Use http://localhost:3000/ or /?url=", + { + status: 400, + headers: corsHeaders, + } + ); + } + + // Decode URL if needed + try { + targetUrl = decodeURIComponent(targetUrl); + } catch { + // URL might not be encoded, use as is + } + + // Validate URL + if (!isValidUrl(targetUrl)) { + logRequest(req.method, targetUrl, 400); + return new Response("Invalid URL provided", { + status: 400, + headers: corsHeaders, + }); + } + + // Proxy the request + const response = await proxyRequest(targetUrl); + logRequest(req.method, targetUrl, response.status); + return response; + }, + error(error) { + console.error("Server error:", error); + return new Response("Internal Server Error", { + status: 500, + headers: corsHeaders, + }); + }, +}); + +console.log(`🚀 CORS Proxy Server running on http://localhost:${server.port}`); +console.log(`📋 Health check: http://localhost:${server.port}/health`); +console.log(`🌍 Environment: ${Bun.env.NODE_ENV || "development"}`); diff --git a/cors-proxy/tsconfig.json b/cors-proxy/tsconfig.json new file mode 100644 index 0000000..194409e --- /dev/null +++ b/cors-proxy/tsconfig.json @@ -0,0 +1,43 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + // File Layout + // "rootDir": "./src", + // "outDir": "./dist", + + // Environment Settings + // See also https://aka.ms/tsconfig/module + "module": "nodenext", + "target": "esnext", + "types": [], + // For nodejs: + // "lib": ["esnext"], + // "types": ["node"], + // and npm install -D @types/node + + // Other Outputs + "sourceMap": true, + "declaration": true, + "declarationMap": true, + + // Stricter Typechecking Options + "noUncheckedIndexedAccess": true, + + // Style Options + // "noImplicitReturns": true, + // "noImplicitOverride": true, + // "noUnusedLocals": true, + // "noUnusedParameters": true, + // "noFallthroughCasesInSwitch": true, + // "noPropertyAccessFromIndexSignature": true, + + // Recommended Options + "strict": true, + "jsx": "react-jsx", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true, + } +}