Skip to content

Commit

Permalink
feat: Encrypt/decrypt S3 keys
Browse files Browse the repository at this point in the history
  • Loading branch information
richiemcilroy committed Nov 23, 2024
1 parent 4aca804 commit ea80881
Show file tree
Hide file tree
Showing 13 changed files with 336 additions and 181 deletions.
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ NEXT_PUBLIC_LOCAL_MODE=false
# which will automatically be created on pnpm dev.
# It is used for local development only.
# For production, use the DATABASE_URL from your DB provider. Must include https for production use, mysql:// for local.
# DATABASE_ENCRYPTION_KEY is used to encrypt sensitive data in the database (like S3 credentials). Not required for local development.
DATABASE_URL=mysql://root:@localhost:3306/planetscale
DATABASE_ENCRYPTION_KEY=

# This is the secret for the NextAuth.js authentication library.
# It is used for local development only.
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1500,7 +1500,10 @@ fn list_screenshots(app: AppHandle) -> Result<Vec<(String, PathBuf, RecordingMet
#[specta::specta]
async fn check_upgraded_and_update(app: AppHandle) -> Result<bool, String> {
if let Err(e) = AuthStore::fetch_and_update_plan(&app).await {
return Err(format!("Failed to update plan information: {}", e));
return Err(format!(
"Failed to update plan information. Try signing out and signing back in: {}",
e
));
}

let auth = AuthStore::get(&app).map_err(|e| e.to_string())?;
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/routes/(window-chrome)/(main).tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export default function () {
commands.showWindow({ Settings: { page: "apps" } })
}
>
<IconLucideCable class="w-[1.25rem] h-[1.25rem] text-gray-400 hover:text-gray-500" />
<IconLucideLayoutGrid class="w-[1.25rem] h-[1.25rem] text-gray-400 hover:text-gray-500" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/routes/(window-chrome)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default function Settings(props: RouteSectionProps) {
{
href: "apps",
name: "Cap Apps",
icon: IconLucideCable,
icon: IconLucideLayoutGrid,
},
{
href: "feedback",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,24 @@ export default function S3ConfigPage() {
</div>
) : (
<div class="space-y-4">
<div>
<p class="text-gray-400 text-sm">
It should take under 10 minutes to set up and connect your S3
bucket to Cap. View the{" "}
<a
href="https://cap.so/docs/s3-config"
target="_blank"
class="text-gray-500 font-semibold underline"
>
S3 Config Guide
</a>{" "}
to get started.
</p>
</div>
<div>
<label class="text-gray-500 text-sm">Access Key ID</label>
<input
type="text"
type="password"
value={accessKeyId()}
onInput={(
e: InputEvent & { currentTarget: HTMLInputElement }
Expand Down
14 changes: 12 additions & 2 deletions apps/web/app/api/desktop/s3/config/get/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { db } from "@cap/database";
import { s3Buckets } from "@cap/database/schema";
import { getCurrentUser } from "@cap/database/auth/session";
import { eq } from "drizzle-orm";
import { decrypt } from "@cap/database/crypto";
import { cookies } from "next/headers";

const allowedOrigins = [
Expand Down Expand Up @@ -70,7 +71,7 @@ export async function GET(request: NextRequest) {
});
}

const config = await db
const encryptedConfig = await db
.select({
accessKeyId: s3Buckets.accessKeyId,
secretAccessKey: s3Buckets.secretAccessKey,
Expand All @@ -81,9 +82,18 @@ export async function GET(request: NextRequest) {
.from(s3Buckets)
.where(eq(s3Buckets.ownerId, user.id));

// Decrypt the config before sending
const config = encryptedConfig[0] ? {
accessKeyId: decrypt(encryptedConfig[0].accessKeyId),
secretAccessKey: decrypt(encryptedConfig[0].secretAccessKey),
endpoint: encryptedConfig[0].endpoint ? decrypt(encryptedConfig[0].endpoint) : null,
bucketName: decrypt(encryptedConfig[0].bucketName),
region: decrypt(encryptedConfig[0].region),
} : null;

return new Response(
JSON.stringify({
config: config[0] || null,
config: config,
}),
{
status: 200,
Expand Down
189 changes: 62 additions & 127 deletions apps/web/app/api/desktop/s3/config/route.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,36 @@
import { type NextRequest } from "next/server";
import { db } from "@cap/database";
import { s3Buckets, users } from "@cap/database/schema";
import { s3Buckets } from "@cap/database/schema";
import { getCurrentUser } from "@cap/database/auth/session";
import { eq } from "drizzle-orm";
import { encrypt } from "@cap/database/crypto";
import { nanoId } from "@cap/database/helpers";
import { getCorsHeaders, getOptionsHeaders } from "@/utils/cors";
import { cookies } from "next/headers";

const allowedOrigins = [
process.env.NEXT_PUBLIC_URL,
"http://localhost:3001",
"http://localhost:3000",
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost",
];

export async function OPTIONS(req: NextRequest) {
console.log("[S3 Config] OPTIONS request received");
const params = req.nextUrl.searchParams;
const origin = params.get("origin") || null;
const originalOrigin = req.nextUrl.origin;

console.log("Handling OPTIONS request");
console.log("OPTIONS request params:", { origin, originalOrigin });

console.log("[S3 Config] Responding to OPTIONS request", { origin, originalOrigin });
return new Response(null, {
status: 200,
headers: {
"Access-Control-Allow-Origin":
origin && allowedOrigins.includes(origin)
? origin
: allowedOrigins.includes(originalOrigin)
? originalOrigin
: "null",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, sentry-trace, baggage",
},
headers: getOptionsHeaders(origin, originalOrigin, "POST, OPTIONS"),
});
}

export async function POST(request: NextRequest) {
console.log("Handling POST request");

console.log("[S3 Config] POST request received");
const params = request.nextUrl.searchParams;
const origin = params.get("origin") || null;
const originalOrigin = request.nextUrl.origin;

// Handle authentication token
const token = request.headers.get("authorization")?.split(" ")[1];
if (token) {
console.log("Setting session token cookie");
console.log("[S3 Config] Setting auth token cookie");
cookies().set({
name: "next-auth.session-token",
value: token,
Expand All @@ -54,133 +39,83 @@ export async function POST(request: NextRequest) {
secure: true,
httpOnly: true,
});
} else {
console.log("[S3 Config] No auth token provided");
}

const params = request.nextUrl.searchParams;
const origin = params.get("origin") || null;
const originalOrigin = request.nextUrl.origin;

console.log("POST request params:", { origin, originalOrigin });

try {
console.log("Attempting to save S3 configuration");
const user = await getCurrentUser();
if (!user) {
console.log("User not authenticated");
console.log("[S3 Config] User not authenticated");
return new Response(JSON.stringify({ error: "Not authenticated" }), {
status: 401,
headers: {
"Access-Control-Allow-Origin":
origin && allowedOrigins.includes(origin)
? origin
: allowedOrigins.includes(originalOrigin)
? originalOrigin
: "null",
"Access-Control-Allow-Credentials": "true",
},
headers: getCorsHeaders(origin, originalOrigin),
});
}

console.log("User authenticated:", user.id);

const body = await request.json();
const { accessKeyId, secretAccessKey, endpoint, bucketName, region } = body;
console.log("[S3 Config] User authenticated", { userId: user.id });

console.log("Received S3 config request:", { endpoint, bucketName, region });
const { accessKeyId, secretAccessKey, endpoint, bucketName, region } =
await request.json();

// Validate required fields
if (!accessKeyId || !secretAccessKey || !bucketName) {
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{
status: 400,
headers: {
"Access-Control-Allow-Origin":
origin && allowedOrigins.includes(origin)
? origin
: allowedOrigins.includes(originalOrigin)
? originalOrigin
: "null",
"Access-Control-Allow-Credentials": "true",
},
}
);
}
console.log("[S3 Config] Received S3 config data", {
hasAccessKeyId: !!accessKeyId,
hasSecretKey: !!secretAccessKey,
endpoint,
bucketName,
region
});

console.log("Checking for existing S3 configuration");
const existingConfigs = await db
// Get existing bucket for this user
const existingBucket = await db
.select()
.from(s3Buckets)
.where(eq(s3Buckets.ownerId, user.id));

const existingConfig = existingConfigs[0];
console.log("Existing config found:", !!existingConfig);
console.log("[S3 Config] Existing bucket found:", { exists: existingBucket.length > 0 });

let bucketId: string;
if (existingConfig) {
bucketId = existingConfig.id;
console.log("Updating existing S3 configuration");
await db
.update(s3Buckets)
.set({
accessKeyId,
secretAccessKey,
endpoint,
bucketName,
region,
})
.where(eq(s3Buckets.id, bucketId));
} else {
console.log("Creating new S3 configuration for user:", user.id);
bucketId = nanoId();
await db.insert(s3Buckets).values({
id: bucketId,
ownerId: user.id,
region: region || "us-east-1",
endpoint,
bucketName,
accessKeyId,
secretAccessKey,
});
console.log("Successfully created new S3 configuration");
}
// Encrypt sensitive data before storing
const encryptedData = {
id: existingBucket[0]?.id || nanoId(),
accessKeyId: encrypt(accessKeyId),
secretAccessKey: encrypt(secretAccessKey),
endpoint: endpoint ? encrypt(endpoint) : null,
bucketName: encrypt(bucketName),
region: encrypt(region),
ownerId: user.id,
};

console.log("[S3 Config] Encrypted data prepared", { id: encryptedData.id });

console.log("Updating user's customBucket field");
await db
.update(users)
.set({
customBucket: bucketId,
})
.where(eq(users.id, user.id));
.insert(s3Buckets)
.values(encryptedData)
.onDuplicateKeyUpdate({
set: {
accessKeyId: encryptedData.accessKeyId,
secretAccessKey: encryptedData.secretAccessKey,
endpoint: encryptedData.endpoint,
bucketName: encryptedData.bucketName,
region: encryptedData.region,
},
});

console.log("[S3 Config] Successfully saved S3 configuration");

console.log("S3 configuration saved successfully");
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: {
"Access-Control-Allow-Origin":
origin && allowedOrigins.includes(origin)
? origin
: allowedOrigins.includes(originalOrigin)
? originalOrigin
: "null",
"Access-Control-Allow-Credentials": "true",
},
headers: getCorsHeaders(origin, originalOrigin),
});
} catch (error) {
console.error("Error saving S3 config:", error);
console.error("[S3 Config] Error saving S3 config:", error);
return new Response(
JSON.stringify({ error: "Failed to save S3 configuration" }),
JSON.stringify({
error: "Failed to save S3 configuration",
details: error instanceof Error ? error.message : 'Unknown error'
}),
{
status: 500,
headers: {
"Access-Control-Allow-Origin":
origin && allowedOrigins.includes(origin)
? origin
: allowedOrigins.includes(originalOrigin)
? originalOrigin
: "null",
"Access-Control-Allow-Credentials": "true",
},
headers: getCorsHeaders(origin, originalOrigin),
}
);
}
Expand Down
Loading

0 comments on commit ea80881

Please sign in to comment.