mirror of
https://github.com/alexandernicholson/s3panoramic.git
synced 2026-03-31 09:07:11 +09:00
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -7,7 +7,7 @@ name: Docker
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: [ "main", "dev" ]
|
||||||
tags: [ "*" ]
|
tags: [ "*" ]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Use the official Deno image
|
# Use the official Deno image
|
||||||
FROM denoland/deno:1.46.3
|
FROM denoland/deno:2.0.6
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
25
deno.lock
generated
25
deno.lock
generated
@@ -1,14 +1,21 @@
|
|||||||
{
|
{
|
||||||
"version": "3",
|
"version": "4",
|
||||||
"packages": {
|
"specifiers": {
|
||||||
"specifiers": {
|
"jsr:@b-fuze/deno-dom@*": "0.1.48",
|
||||||
"npm:hono": "npm:hono@4.6.10"
|
"jsr:@libs/xml@*": "6.0.1",
|
||||||
|
"npm:hono@*": "4.6.10"
|
||||||
|
},
|
||||||
|
"jsr": {
|
||||||
|
"@b-fuze/deno-dom@0.1.48": {
|
||||||
|
"integrity": "bf5b591aef2e9e9c59adfcbb93a9ecd45bab5b7c8263625beafa5c8f1662e7da"
|
||||||
},
|
},
|
||||||
"npm": {
|
"@libs/xml@6.0.1": {
|
||||||
"hono@4.6.10": {
|
"integrity": "64af4f93464c77c3e1158fb97c3657779ca554b14f38616b96cde31e22d8a309"
|
||||||
"integrity": "sha512-IXXNfRAZEahFnWBhUUlqKEGF9upeE6hZoRZszvNkyAz/CYp+iVbxm3viMvStlagRJohjlBRGOQ7f4jfcV0XMGg==",
|
}
|
||||||
"dependencies": {}
|
},
|
||||||
}
|
"npm": {
|
||||||
|
"hono@4.6.10": {
|
||||||
|
"integrity": "sha512-IXXNfRAZEahFnWBhUUlqKEGF9upeE6hZoRZszvNkyAz/CYp+iVbxm3viMvStlagRJohjlBRGOQ7f4jfcV0XMGg=="
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"redirects": {
|
"redirects": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ListObjectsOptions, ListObjectsResult, StorageObject } from "../types/m
|
|||||||
import { S3, type ListObjectsV2Request } from "https://deno.land/x/aws_api@v0.8.1/services/s3/mod.ts";
|
import { S3, type ListObjectsV2Request } from "https://deno.land/x/aws_api@v0.8.1/services/s3/mod.ts";
|
||||||
import { ApiFactory } from "https://deno.land/x/aws_api@v0.8.1/client/mod.ts";
|
import { ApiFactory } from "https://deno.land/x/aws_api@v0.8.1/client/mod.ts";
|
||||||
import { getSignedUrl } from "https://deno.land/x/aws_s3_presign@2.2.1/mod.ts";
|
import { getSignedUrl } from "https://deno.land/x/aws_s3_presign@2.2.1/mod.ts";
|
||||||
|
import { parse as parseXml } from "jsr:@libs/xml";
|
||||||
|
|
||||||
interface Credentials {
|
interface Credentials {
|
||||||
awsAccessKeyId: string;
|
awsAccessKeyId: string;
|
||||||
@@ -11,10 +12,14 @@ interface Credentials {
|
|||||||
|
|
||||||
export class StorageService {
|
export class StorageService {
|
||||||
private client: S3;
|
private client: S3;
|
||||||
private credentials: Credentials;
|
private credentials!: Credentials;
|
||||||
|
|
||||||
constructor(private bucket: string, private region: string) {
|
constructor(private bucket: string, private region: string) {
|
||||||
this.credentials = this.resolveCredentials();
|
this.initializeClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeClient() {
|
||||||
|
this.credentials = await this.resolveCredentials();
|
||||||
const factory = new ApiFactory({
|
const factory = new ApiFactory({
|
||||||
region: this.region,
|
region: this.region,
|
||||||
credentials: this.credentials,
|
credentials: this.credentials,
|
||||||
@@ -23,28 +28,35 @@ export class StorageService {
|
|||||||
this.client = new S3(factory);
|
this.client = new S3(factory);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchInstanceProfileCredentials(): Promise<Credentials | null> {
|
// Make sure any method that uses this.client waits for initialization
|
||||||
|
private async ensureInitialized() {
|
||||||
|
if (!this.client) {
|
||||||
|
await this.initializeClient();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchInstanceProfileCredentials(): Promise<[Credentials | null, string | null]> {
|
||||||
try {
|
try {
|
||||||
const metadataUrl = "http://169.254.169.254/latest/meta-data/iam/security-credentials/";
|
const metadataUrl = "http://169.254.169.254/latest/meta-data/iam/security-credentials/";
|
||||||
const roleName = await (await fetch(metadataUrl)).text();
|
const roleName = await (await fetch(metadataUrl)).text();
|
||||||
const credentials = await (await fetch(`${metadataUrl}${roleName}`)).json();
|
const credentials = await (await fetch(`${metadataUrl}${roleName}`)).json();
|
||||||
|
|
||||||
return {
|
return [{
|
||||||
awsAccessKeyId: credentials.AccessKeyId,
|
awsAccessKeyId: credentials.AccessKeyId,
|
||||||
awsSecretKey: credentials.SecretAccessKey,
|
awsSecretKey: credentials.SecretAccessKey,
|
||||||
sessionToken: credentials.Token,
|
sessionToken: credentials.Token,
|
||||||
};
|
}, null];
|
||||||
} catch {
|
} catch (error) {
|
||||||
return null;
|
return [null, `Instance profile credentials failed: ${error instanceof Error ? error.message : String(error)}`];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchIRSACredentials(): Promise<Credentials | null> {
|
private async fetchIRSACredentials(): Promise<[Credentials | null, string | null]> {
|
||||||
const roleArn = Deno.env.get("AWS_ROLE_ARN");
|
const roleArn = Deno.env.get("AWS_ROLE_ARN");
|
||||||
const tokenFile = Deno.env.get("AWS_WEB_IDENTITY_TOKEN_FILE");
|
const tokenFile = Deno.env.get("AWS_WEB_IDENTITY_TOKEN_FILE");
|
||||||
|
|
||||||
if (!roleArn || !tokenFile) {
|
if (!roleArn || !tokenFile) {
|
||||||
return null;
|
return [null, "IRSA credentials not configured: missing AWS_ROLE_ARN or AWS_WEB_IDENTITY_TOKEN_FILE"];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -52,47 +64,53 @@ export class StorageService {
|
|||||||
const sts = new WebIdentityCredentials(roleArn, token);
|
const sts = new WebIdentityCredentials(roleArn, token);
|
||||||
const creds = await sts.getCredentials();
|
const creds = await sts.getCredentials();
|
||||||
|
|
||||||
return {
|
return [{
|
||||||
awsAccessKeyId: creds.accessKeyId,
|
awsAccessKeyId: creds.accessKeyId,
|
||||||
awsSecretKey: creds.secretAccessKey,
|
awsSecretKey: creds.secretAccessKey,
|
||||||
sessionToken: creds.sessionToken,
|
sessionToken: creds.sessionToken,
|
||||||
};
|
}, null];
|
||||||
} catch {
|
} catch (error) {
|
||||||
return null;
|
return [null, `IRSA credentials failed: ${error instanceof Error ? error.message : String(error)}`];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getStaticCredentials(): Credentials | null {
|
private getStaticCredentials(): [Credentials | null, string | null] {
|
||||||
const accessKeyId = Deno.env.get("AWS_ACCESS_KEY_ID");
|
const accessKeyId = Deno.env.get("AWS_ACCESS_KEY_ID");
|
||||||
const secretKey = Deno.env.get("AWS_SECRET_ACCESS_KEY");
|
const secretKey = Deno.env.get("AWS_SECRET_ACCESS_KEY");
|
||||||
|
|
||||||
if (!accessKeyId || !secretKey) {
|
if (!accessKeyId || !secretKey) {
|
||||||
return null;
|
return [null, "Static credentials not configured: missing AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY"];
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return [{
|
||||||
awsAccessKeyId: accessKeyId,
|
awsAccessKeyId: accessKeyId,
|
||||||
awsSecretKey: secretKey,
|
awsSecretKey: secretKey,
|
||||||
};
|
}, null];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveCredentials(): Promise<Credentials> {
|
private async resolveCredentials(): Promise<Credentials> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
// Try IRSA first
|
// Try IRSA first
|
||||||
const irsaCreds = await this.fetchIRSACredentials();
|
const [irsaCreds, irsaError] = await this.fetchIRSACredentials();
|
||||||
if (irsaCreds) return irsaCreds;
|
if (irsaCreds) return irsaCreds;
|
||||||
|
if (irsaError) errors.push(irsaError);
|
||||||
|
|
||||||
// Then try static credentials
|
// Then try static credentials
|
||||||
const staticCreds = this.getStaticCredentials();
|
const [staticCreds, staticError] = this.getStaticCredentials();
|
||||||
if (staticCreds) return staticCreds;
|
if (staticCreds) return staticCreds;
|
||||||
|
if (staticError) errors.push(staticError);
|
||||||
|
|
||||||
// Finally try instance profile
|
// Finally try instance profile
|
||||||
const instanceCreds = await this.fetchInstanceProfileCredentials();
|
const [instanceCreds, instanceError] = await this.fetchInstanceProfileCredentials();
|
||||||
if (instanceCreds) return instanceCreds;
|
if (instanceCreds) return instanceCreds;
|
||||||
|
if (instanceError) errors.push(instanceError);
|
||||||
|
|
||||||
throw new Error("No valid AWS credentials found");
|
throw new Error(`No valid AWS credentials found:\n${errors.join('\n')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
|
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
|
||||||
|
await this.ensureInitialized();
|
||||||
return await getSignedUrl({
|
return await getSignedUrl({
|
||||||
accessKeyId: this.credentials.awsAccessKeyId,
|
accessKeyId: this.credentials.awsAccessKeyId,
|
||||||
secretAccessKey: this.credentials.awsSecretKey,
|
secretAccessKey: this.credentials.awsSecretKey,
|
||||||
@@ -104,6 +122,7 @@ export class StorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listObjects(options: ListObjectsOptions): Promise<ListObjectsResult> {
|
async listObjects(options: ListObjectsOptions): Promise<ListObjectsResult> {
|
||||||
|
await this.ensureInitialized();
|
||||||
try {
|
try {
|
||||||
const params: ListObjectsV2Request = {
|
const params: ListObjectsV2Request = {
|
||||||
Bucket: this.bucket,
|
Bucket: this.bucket,
|
||||||
@@ -169,24 +188,54 @@ class WebIdentityCredentials {
|
|||||||
WebIdentityToken: this.token,
|
WebIdentityToken: this.token,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(`https://sts.amazonaws.com?${params}`, {
|
const url = `https://sts.amazonaws.com?${params}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to assume role: ${await response.text()}`);
|
console.error('IRSA Request Failed:', {
|
||||||
|
url,
|
||||||
|
requestHeaders: {
|
||||||
|
method: "GET",
|
||||||
|
RoleArn: this.roleArn,
|
||||||
|
TokenLength: this.token.length,
|
||||||
|
},
|
||||||
|
responseStatus: response.status,
|
||||||
|
responseHeaders: Object.fromEntries(response.headers),
|
||||||
|
responseBody: responseText
|
||||||
|
});
|
||||||
|
throw new Error(`Failed to assume role: ${responseText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const xml = await response.text();
|
const result = parseXml(responseText);
|
||||||
const result = new DOMParser().parseFromString(xml, "text/xml");
|
const credentials = result.AssumeRoleWithWebIdentityResponse?.AssumeRoleWithWebIdentityResult?.Credentials;
|
||||||
|
|
||||||
const credentials = result.querySelector("Credentials");
|
if (!credentials) {
|
||||||
if (!credentials) throw new Error("No credentials in response");
|
console.error('IRSA Parsing Failed:', {
|
||||||
|
parsedXml: JSON.stringify(result, null, 2),
|
||||||
|
rawResponse: responseText
|
||||||
|
});
|
||||||
|
throw new Error("No credentials in response. Full response: " + JSON.stringify(result, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { AccessKeyId, SecretAccessKey, SessionToken } = credentials;
|
||||||
|
if (!AccessKeyId || !SecretAccessKey || !SessionToken) {
|
||||||
|
console.error('IRSA Missing Fields:', {
|
||||||
|
hasAccessKeyId: !!AccessKeyId,
|
||||||
|
hasSecretAccessKey: !!SecretAccessKey,
|
||||||
|
hasSessionToken: !!SessionToken,
|
||||||
|
parsedCredentials: credentials
|
||||||
|
});
|
||||||
|
throw new Error("Missing required credential fields in response");
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessKeyId: credentials.querySelector("AccessKeyId")?.textContent ?? "",
|
accessKeyId: AccessKeyId,
|
||||||
secretAccessKey: credentials.querySelector("SecretAccessKey")?.textContent ?? "",
|
secretAccessKey: SecretAccessKey,
|
||||||
sessionToken: credentials.querySelector("SessionToken")?.textContent ?? "",
|
sessionToken: SessionToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user