@anthusai/papyrus 1.0.0-next.44 → 1.0.0-next.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/functions/content-actions/requirements.txt +1 -1
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/renditions/handler.py +94 -0
- package/amplify/functions/renditions/requirements.txt +4 -0
- package/amplify/publication/renditions-function.js +74 -0
- package/amplify/publication/renditions-function.ts +112 -0
- package/amplify/publication/renditions-settings.js +43 -0
- package/amplify/publication/renditions-settings.ts +50 -0
- package/amplify/publication/s3-projection.js +11 -2
- package/amplify/publication/s3-projection.ts +24 -2
- package/amplify/site-backend.js +8 -3
- package/amplify/site-backend.ts +8 -3
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { pythonLambdaCode } from "../shared/python-bundle.js";
|
|
8
8
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
const projectRoot = path.resolve(dirname, "../../..");
|
|
10
|
-
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=
|
|
10
|
+
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
|
|
11
11
|
function installCheckoutRequirements(outputDir) {
|
|
12
12
|
const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
|
|
13
13
|
execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
|
|
@@ -10,7 +10,7 @@ import { pythonLambdaCode, type SiteBundleFile } from "../shared/python-bundle";
|
|
|
10
10
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
const projectRoot = path.resolve(dirname, "../../..");
|
|
12
12
|
|
|
13
|
-
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=
|
|
13
|
+
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
|
|
14
14
|
|
|
15
15
|
function installCheckoutRequirements(outputDir: string): void {
|
|
16
16
|
const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from papyrus_content.projection.runtime import SITE_BUCKET_ENV, SITE_STATIC_DIRECTORY_ENV
|
|
10
|
+
from papyrus_content.renditions import (
|
|
11
|
+
RenditionSettings,
|
|
12
|
+
S3RenditionStore,
|
|
13
|
+
make_renditions,
|
|
14
|
+
meta_key,
|
|
15
|
+
settings_from_static_directory,
|
|
16
|
+
source_from_media_key,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_loaded: dict[str, Any] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def configured_settings() -> RenditionSettings | None:
|
|
23
|
+
if "settings" not in _loaded:
|
|
24
|
+
static_directory = (os.environ.get(SITE_STATIC_DIRECTORY_ENV) or "").strip()
|
|
25
|
+
_loaded["settings"] = settings_from_static_directory(Path(static_directory) if static_directory else None)
|
|
26
|
+
return _loaded["settings"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def site_store() -> S3RenditionStore:
|
|
30
|
+
if "store" not in _loaded:
|
|
31
|
+
_loaded["store"] = S3RenditionStore(os.environ[SITE_BUCKET_ENV])
|
|
32
|
+
return _loaded["store"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def s3_client():
|
|
36
|
+
if "s3" not in _loaded:
|
|
37
|
+
import boto3
|
|
38
|
+
|
|
39
|
+
_loaded["s3"] = boto3.client("s3")
|
|
40
|
+
return _loaded["s3"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def media_objects(event: dict) -> list[tuple[str, str]]:
|
|
44
|
+
if event.get("source") == "aws.s3" and "detail" in event:
|
|
45
|
+
detail = event["detail"]
|
|
46
|
+
return [(detail["bucket"]["name"], detail["object"]["key"])]
|
|
47
|
+
if "bucket" in event and "key" in event:
|
|
48
|
+
return [(event["bucket"], event["key"])]
|
|
49
|
+
raise ValueError("The event is neither an S3 object event nor a {bucket, key} request.")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def render_media_object(
|
|
53
|
+
settings: RenditionSettings, bucket: str, key: str, *, skip_existing: bool = False, dry_run: bool = False
|
|
54
|
+
) -> dict:
|
|
55
|
+
source = source_from_media_key(key)
|
|
56
|
+
if source is None:
|
|
57
|
+
return {"key": key, "action": "ignored", "reason": "not an image under media/"}
|
|
58
|
+
if skip_existing and site_store().get(meta_key(settings.pipeline, source)) is not None:
|
|
59
|
+
return {"key": key, "source": source, "action": "skipped-existing"}
|
|
60
|
+
if dry_run:
|
|
61
|
+
return {"key": key, "source": source, "action": "would-render"}
|
|
62
|
+
started = time.perf_counter()
|
|
63
|
+
data = s3_client().get_object(Bucket=bucket, Key=key)["Body"].read()
|
|
64
|
+
downloaded = time.perf_counter()
|
|
65
|
+
result = make_renditions(settings, site_store(), source, data)
|
|
66
|
+
finished = time.perf_counter()
|
|
67
|
+
return {
|
|
68
|
+
"key": key,
|
|
69
|
+
"source": source,
|
|
70
|
+
"action": "rendered",
|
|
71
|
+
"kind": result["kind"],
|
|
72
|
+
"written": result["written"],
|
|
73
|
+
"sourceBytes": len(data),
|
|
74
|
+
"downloadMilliseconds": round((downloaded - started) * 1000),
|
|
75
|
+
"renditionMilliseconds": round((finished - downloaded) * 1000),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def handler(event, context):
|
|
80
|
+
settings = configured_settings()
|
|
81
|
+
if settings is None:
|
|
82
|
+
return {"ok": True, "results": [], "skipped": "this site has no images settings in projection.json"}
|
|
83
|
+
results = [
|
|
84
|
+
render_media_object(
|
|
85
|
+
settings,
|
|
86
|
+
bucket,
|
|
87
|
+
key,
|
|
88
|
+
skip_existing=event.get("skipExisting") is True,
|
|
89
|
+
dry_run=event.get("dryRun") is True,
|
|
90
|
+
)
|
|
91
|
+
for bucket, key in media_objects(event)
|
|
92
|
+
]
|
|
93
|
+
print(json.dumps({"renditions": results}))
|
|
94
|
+
return {"ok": True, "results": results}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Duration, Size } from "aws-cdk-lib";
|
|
2
|
+
import * as events from "aws-cdk-lib/aws-events";
|
|
3
|
+
import * as targets from "aws-cdk-lib/aws-events-targets";
|
|
4
|
+
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
5
|
+
import { Architecture, Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
6
|
+
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { pythonLambdaCode } from "../functions/shared/python-bundle.js";
|
|
11
|
+
import { RENDITIONS_EPHEMERAL_STORAGE_MB, RENDITIONS_MEDIA_PREFIX, RENDITIONS_MEMORY_MB, RENDITIONS_TIMEOUT_SECONDS, renditionsEnvironment, renditionsEventPattern, } from "./renditions-settings.js";
|
|
12
|
+
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const projectRoot = path.resolve(dirname, "../..");
|
|
14
|
+
export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12"];
|
|
15
|
+
function installCheckoutRequirements(outputDir) {
|
|
16
|
+
const requirementsPath = path.join(projectRoot, "amplify/functions/renditions/requirements.txt");
|
|
17
|
+
execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
|
|
18
|
+
}
|
|
19
|
+
export function addRenditionsFunction(scope, props) {
|
|
20
|
+
const deadLetterQueue = new sqs.Queue(scope, "RenditionsDeadLetterQueue", {
|
|
21
|
+
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
22
|
+
retentionPeriod: Duration.days(14),
|
|
23
|
+
enforceSSL: true,
|
|
24
|
+
});
|
|
25
|
+
const renditions = new LambdaFunction(scope, "RenditionsFunction", {
|
|
26
|
+
runtime: Runtime.PYTHON_3_12,
|
|
27
|
+
architecture: Architecture.ARM_64,
|
|
28
|
+
handler: "handler.handler",
|
|
29
|
+
timeout: Duration.seconds(RENDITIONS_TIMEOUT_SECONDS),
|
|
30
|
+
memorySize: RENDITIONS_MEMORY_MB,
|
|
31
|
+
ephemeralStorageSize: Size.mebibytes(RENDITIONS_EPHEMERAL_STORAGE_MB),
|
|
32
|
+
retryAttempts: 2,
|
|
33
|
+
deadLetterQueue,
|
|
34
|
+
environment: renditionsEnvironment(props.siteBucket.bucketName),
|
|
35
|
+
code: props.code ?? pythonLambdaCode({
|
|
36
|
+
functionDir: "renditions",
|
|
37
|
+
modules: ["papyrus_content"],
|
|
38
|
+
requirements: renditionsRequirements,
|
|
39
|
+
noDeps: true,
|
|
40
|
+
checkoutExtras: installCheckoutRequirements,
|
|
41
|
+
siteFiles: props.siteFiles,
|
|
42
|
+
}, [
|
|
43
|
+
"bash",
|
|
44
|
+
"-c",
|
|
45
|
+
[
|
|
46
|
+
"set -euo pipefail",
|
|
47
|
+
"mkdir -p /asset-output/papyrus_content",
|
|
48
|
+
"cp amplify/functions/renditions/handler.py /asset-output/handler.py",
|
|
49
|
+
"cp -R src/papyrus_content/. /asset-output/papyrus_content/",
|
|
50
|
+
"python3 -m pip install -r amplify/functions/renditions/requirements.txt -t /asset-output --no-cache-dir",
|
|
51
|
+
].join(" && "),
|
|
52
|
+
]),
|
|
53
|
+
});
|
|
54
|
+
renditions.addToRolePolicy(new PolicyStatement({
|
|
55
|
+
effect: Effect.ALLOW,
|
|
56
|
+
actions: ["s3:GetObject"],
|
|
57
|
+
resources: [props.mediaBucket.arnForObjects(`${RENDITIONS_MEDIA_PREFIX}*`)],
|
|
58
|
+
}));
|
|
59
|
+
renditions.addToRolePolicy(new PolicyStatement({
|
|
60
|
+
effect: Effect.ALLOW,
|
|
61
|
+
actions: ["s3:PutObject", "s3:GetObject"],
|
|
62
|
+
resources: [props.siteBucket.arnForObjects("*")],
|
|
63
|
+
}));
|
|
64
|
+
renditions.addToRolePolicy(new PolicyStatement({
|
|
65
|
+
effect: Effect.ALLOW,
|
|
66
|
+
actions: ["s3:ListBucket"],
|
|
67
|
+
resources: [props.siteBucket.bucketArn],
|
|
68
|
+
}));
|
|
69
|
+
const rule = new events.Rule(scope, "MediaObjectCreatedRule", {
|
|
70
|
+
eventPattern: renditionsEventPattern(props.mediaBucket.bucketName),
|
|
71
|
+
});
|
|
72
|
+
rule.addTarget(new targets.LambdaFunction(renditions, { retryAttempts: 2 }));
|
|
73
|
+
return { function: renditions, rule, deadLetterQueue };
|
|
74
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Duration, Size } from "aws-cdk-lib";
|
|
2
|
+
import * as events from "aws-cdk-lib/aws-events";
|
|
3
|
+
import * as targets from "aws-cdk-lib/aws-events-targets";
|
|
4
|
+
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
5
|
+
import { Architecture, type Code, Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
6
|
+
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
7
|
+
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
8
|
+
import type { Construct } from "constructs";
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { pythonLambdaCode, type SiteBundleFile } from "../functions/shared/python-bundle";
|
|
13
|
+
import {
|
|
14
|
+
RENDITIONS_EPHEMERAL_STORAGE_MB,
|
|
15
|
+
RENDITIONS_MEDIA_PREFIX,
|
|
16
|
+
RENDITIONS_MEMORY_MB,
|
|
17
|
+
RENDITIONS_TIMEOUT_SECONDS,
|
|
18
|
+
renditionsEnvironment,
|
|
19
|
+
renditionsEventPattern,
|
|
20
|
+
} from "./renditions-settings";
|
|
21
|
+
|
|
22
|
+
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const projectRoot = path.resolve(dirname, "../..");
|
|
24
|
+
|
|
25
|
+
export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12"];
|
|
26
|
+
|
|
27
|
+
export type RenditionsFunctionProps = {
|
|
28
|
+
siteBucket: s3.IBucket;
|
|
29
|
+
mediaBucket: s3.IBucket;
|
|
30
|
+
siteFiles: SiteBundleFile[];
|
|
31
|
+
code?: Code;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type RenditionsFunction = {
|
|
35
|
+
function: LambdaFunction;
|
|
36
|
+
rule: events.Rule;
|
|
37
|
+
deadLetterQueue: sqs.Queue;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function installCheckoutRequirements(outputDir: string): void {
|
|
41
|
+
const requirementsPath = path.join(projectRoot, "amplify/functions/renditions/requirements.txt");
|
|
42
|
+
execSync(
|
|
43
|
+
`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`,
|
|
44
|
+
{ cwd: projectRoot, stdio: "inherit" },
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function addRenditionsFunction(scope: Construct, props: RenditionsFunctionProps): RenditionsFunction {
|
|
49
|
+
const deadLetterQueue = new sqs.Queue(scope, "RenditionsDeadLetterQueue", {
|
|
50
|
+
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
51
|
+
retentionPeriod: Duration.days(14),
|
|
52
|
+
enforceSSL: true,
|
|
53
|
+
});
|
|
54
|
+
const renditions = new LambdaFunction(scope, "RenditionsFunction", {
|
|
55
|
+
runtime: Runtime.PYTHON_3_12,
|
|
56
|
+
architecture: Architecture.ARM_64,
|
|
57
|
+
handler: "handler.handler",
|
|
58
|
+
timeout: Duration.seconds(RENDITIONS_TIMEOUT_SECONDS),
|
|
59
|
+
memorySize: RENDITIONS_MEMORY_MB,
|
|
60
|
+
ephemeralStorageSize: Size.mebibytes(RENDITIONS_EPHEMERAL_STORAGE_MB),
|
|
61
|
+
retryAttempts: 2,
|
|
62
|
+
deadLetterQueue,
|
|
63
|
+
environment: renditionsEnvironment(props.siteBucket.bucketName),
|
|
64
|
+
code: props.code ?? pythonLambdaCode(
|
|
65
|
+
{
|
|
66
|
+
functionDir: "renditions",
|
|
67
|
+
modules: ["papyrus_content"],
|
|
68
|
+
requirements: renditionsRequirements,
|
|
69
|
+
noDeps: true,
|
|
70
|
+
checkoutExtras: installCheckoutRequirements,
|
|
71
|
+
siteFiles: props.siteFiles,
|
|
72
|
+
},
|
|
73
|
+
[
|
|
74
|
+
"bash",
|
|
75
|
+
"-c",
|
|
76
|
+
[
|
|
77
|
+
"set -euo pipefail",
|
|
78
|
+
"mkdir -p /asset-output/papyrus_content",
|
|
79
|
+
"cp amplify/functions/renditions/handler.py /asset-output/handler.py",
|
|
80
|
+
"cp -R src/papyrus_content/. /asset-output/papyrus_content/",
|
|
81
|
+
"python3 -m pip install -r amplify/functions/renditions/requirements.txt -t /asset-output --no-cache-dir",
|
|
82
|
+
].join(" && "),
|
|
83
|
+
],
|
|
84
|
+
),
|
|
85
|
+
});
|
|
86
|
+
renditions.addToRolePolicy(
|
|
87
|
+
new PolicyStatement({
|
|
88
|
+
effect: Effect.ALLOW,
|
|
89
|
+
actions: ["s3:GetObject"],
|
|
90
|
+
resources: [props.mediaBucket.arnForObjects(`${RENDITIONS_MEDIA_PREFIX}*`)],
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
renditions.addToRolePolicy(
|
|
94
|
+
new PolicyStatement({
|
|
95
|
+
effect: Effect.ALLOW,
|
|
96
|
+
actions: ["s3:PutObject", "s3:GetObject"],
|
|
97
|
+
resources: [props.siteBucket.arnForObjects("*")],
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
renditions.addToRolePolicy(
|
|
101
|
+
new PolicyStatement({
|
|
102
|
+
effect: Effect.ALLOW,
|
|
103
|
+
actions: ["s3:ListBucket"],
|
|
104
|
+
resources: [props.siteBucket.bucketArn],
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
const rule = new events.Rule(scope, "MediaObjectCreatedRule", {
|
|
108
|
+
eventPattern: renditionsEventPattern(props.mediaBucket.bucketName),
|
|
109
|
+
});
|
|
110
|
+
rule.addTarget(new targets.LambdaFunction(renditions, { retryAttempts: 2 }));
|
|
111
|
+
return { function: renditions, rule, deadLetterQueue };
|
|
112
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning for the upload-time renditions function of an s3-projection
|
|
3
|
+
* site (no CDK imports, so it can be tested without synthesizing).
|
|
4
|
+
*
|
|
5
|
+
* The function exists only for a site whose `projection.json` carries an
|
|
6
|
+
* `images` section: that section is the one place the image pipeline settings
|
|
7
|
+
* live, shared by the renditions function and the projector.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
export const RENDITIONS_MEMORY_MB = 3008;
|
|
12
|
+
export const RENDITIONS_TIMEOUT_SECONDS = 300;
|
|
13
|
+
export const RENDITIONS_EPHEMERAL_STORAGE_MB = 2048;
|
|
14
|
+
export const RENDITIONS_STATIC_DIRECTORY = "/var/task/site_static";
|
|
15
|
+
export const RENDITIONS_MEDIA_PREFIX = "media/";
|
|
16
|
+
export const RENDITIONS_FUNCTION_NAME_ENV = "PAPYRUS_RENDITIONS_FUNCTION_NAME";
|
|
17
|
+
export function projectionSettingsHaveImages(settingsPath) {
|
|
18
|
+
if (!fs.existsSync(settingsPath))
|
|
19
|
+
return false;
|
|
20
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
21
|
+
return typeof parsed.images === "object" && parsed.images !== null;
|
|
22
|
+
}
|
|
23
|
+
export function planRenditions(publication, workingDirectory) {
|
|
24
|
+
if (publication?.staticDir === undefined)
|
|
25
|
+
return { enabled: false, siteFiles: [] };
|
|
26
|
+
const settingsFile = path.join(publication.staticDir, "projection.json");
|
|
27
|
+
if (!projectionSettingsHaveImages(path.resolve(workingDirectory, settingsFile)))
|
|
28
|
+
return { enabled: false, siteFiles: [] };
|
|
29
|
+
return { enabled: true, siteFiles: [{ from: settingsFile, to: "site_static/projection.json" }] };
|
|
30
|
+
}
|
|
31
|
+
export function renditionsEventPattern(mediaBucketName) {
|
|
32
|
+
return {
|
|
33
|
+
source: ["aws.s3"],
|
|
34
|
+
detailType: ["Object Created"],
|
|
35
|
+
detail: { bucket: { name: [mediaBucketName] }, object: { key: [{ prefix: RENDITIONS_MEDIA_PREFIX }] } },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function renditionsEnvironment(siteBucketName) {
|
|
39
|
+
return {
|
|
40
|
+
PAPYRUS_SITE_BUCKET: siteBucketName,
|
|
41
|
+
PAPYRUS_SITE_STATIC_DIR: RENDITIONS_STATIC_DIRECTORY,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning for the upload-time renditions function of an s3-projection
|
|
3
|
+
* site (no CDK imports, so it can be tested without synthesizing).
|
|
4
|
+
*
|
|
5
|
+
* The function exists only for a site whose `projection.json` carries an
|
|
6
|
+
* `images` section: that section is the one place the image pipeline settings
|
|
7
|
+
* live, shared by the renditions function and the projector.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
export const RENDITIONS_MEMORY_MB = 3008;
|
|
13
|
+
export const RENDITIONS_TIMEOUT_SECONDS = 300;
|
|
14
|
+
export const RENDITIONS_EPHEMERAL_STORAGE_MB = 2048;
|
|
15
|
+
export const RENDITIONS_STATIC_DIRECTORY = "/var/task/site_static";
|
|
16
|
+
export const RENDITIONS_MEDIA_PREFIX = "media/";
|
|
17
|
+
export const RENDITIONS_FUNCTION_NAME_ENV = "PAPYRUS_RENDITIONS_FUNCTION_NAME";
|
|
18
|
+
|
|
19
|
+
export type RenditionsPlan = {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
siteFiles: Array<{ from: string; to: string }>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function projectionSettingsHaveImages(settingsPath: string): boolean {
|
|
25
|
+
if (!fs.existsSync(settingsPath)) return false;
|
|
26
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as Record<string, unknown>;
|
|
27
|
+
return typeof parsed.images === "object" && parsed.images !== null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function planRenditions(publication: { staticDir?: string } | undefined, workingDirectory: string): RenditionsPlan {
|
|
31
|
+
if (publication?.staticDir === undefined) return { enabled: false, siteFiles: [] };
|
|
32
|
+
const settingsFile = path.join(publication.staticDir, "projection.json");
|
|
33
|
+
if (!projectionSettingsHaveImages(path.resolve(workingDirectory, settingsFile))) return { enabled: false, siteFiles: [] };
|
|
34
|
+
return { enabled: true, siteFiles: [{ from: settingsFile, to: "site_static/projection.json" }] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function renditionsEventPattern(mediaBucketName: string): Record<string, unknown> {
|
|
38
|
+
return {
|
|
39
|
+
source: ["aws.s3"],
|
|
40
|
+
detailType: ["Object Created"],
|
|
41
|
+
detail: { bucket: { name: [mediaBucketName] }, object: { key: [{ prefix: RENDITIONS_MEDIA_PREFIX }] } },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function renditionsEnvironment(siteBucketName: string): Record<string, string> {
|
|
46
|
+
return {
|
|
47
|
+
PAPYRUS_SITE_BUCKET: siteBucketName,
|
|
48
|
+
PAPYRUS_SITE_STATIC_DIR: RENDITIONS_STATIC_DIRECTORY,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -9,6 +9,8 @@ import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
|
9
9
|
import { dirname, resolve } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { assertValidPublicationConfig } from "../site-backend-config.js";
|
|
12
|
+
import { addRenditionsFunction } from "./renditions-function.js";
|
|
13
|
+
import { RENDITIONS_FUNCTION_NAME_ENV } from "./renditions-settings.js";
|
|
12
14
|
import { s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings.js";
|
|
13
15
|
const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
|
|
14
16
|
function createSiteBucket(scope, retainBucketOnDelete) {
|
|
@@ -60,7 +62,7 @@ function createDistribution(scope, props, bucket, redirectFunction) {
|
|
|
60
62
|
],
|
|
61
63
|
});
|
|
62
64
|
}
|
|
63
|
-
export function addS3ProjectionPublication(scope, props) {
|
|
65
|
+
export function addS3ProjectionPublication(scope, props, media) {
|
|
64
66
|
assertValidPublicationConfig({ kind: "s3-projection", ...props });
|
|
65
67
|
const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
|
|
66
68
|
const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
|
|
@@ -90,6 +92,9 @@ export function addS3ProjectionPublication(scope, props) {
|
|
|
90
92
|
conditions: { StringEquals: { "aws:SourceArn": props.edgeDistributionArn } },
|
|
91
93
|
}));
|
|
92
94
|
}
|
|
95
|
+
const renditions = media?.renditions.enabled === true
|
|
96
|
+
? addRenditionsFunction(scope, { siteBucket: bucket, mediaBucket: media.mediaBucket, siteFiles: media.renditions.siteFiles, code: media.renditionsCode })
|
|
97
|
+
: undefined;
|
|
93
98
|
const settingsInput = {
|
|
94
99
|
bucketName: bucket.bucketName,
|
|
95
100
|
bucketArn: bucket.bucketArn,
|
|
@@ -111,7 +116,11 @@ export function addS3ProjectionPublication(scope, props) {
|
|
|
111
116
|
deadLetterQueue,
|
|
112
117
|
distribution,
|
|
113
118
|
distributionId: distribution?.distributionId,
|
|
114
|
-
|
|
119
|
+
renditions,
|
|
120
|
+
environment: {
|
|
121
|
+
...s3ProjectionEnvironment(settingsInput),
|
|
122
|
+
...(renditions !== undefined ? { [RENDITIONS_FUNCTION_NAME_ENV]: renditions.function.functionName } : {}),
|
|
123
|
+
},
|
|
115
124
|
policyStatements: s3ProjectionPolicyStatements(settingsInput).map((statement) => new PolicyStatement({ effect: Effect.ALLOW, actions: statement.actions, resources: statement.resources })),
|
|
116
125
|
};
|
|
117
126
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
2
2
|
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
|
3
3
|
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
|
|
4
|
+
import type { Code } from "aws-cdk-lib/aws-lambda";
|
|
4
5
|
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
|
|
5
6
|
import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
6
7
|
import * as route53 from "aws-cdk-lib/aws-route53";
|
|
@@ -10,12 +11,20 @@ import type { Construct } from "constructs";
|
|
|
10
11
|
import { dirname, resolve } from "node:path";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
import { assertValidPublicationConfig, type PapyrusPublicationConfig } from "../site-backend-config";
|
|
14
|
+
import { addRenditionsFunction, type RenditionsFunction } from "./renditions-function";
|
|
15
|
+
import { RENDITIONS_FUNCTION_NAME_ENV, type RenditionsPlan } from "./renditions-settings";
|
|
13
16
|
import { s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings";
|
|
14
17
|
|
|
15
18
|
const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
|
|
16
19
|
|
|
17
20
|
export type S3ProjectionPublicationProps = Omit<PapyrusPublicationConfig, "kind"> & { kind?: "s3-projection" };
|
|
18
21
|
|
|
22
|
+
export type S3ProjectionMedia = {
|
|
23
|
+
mediaBucket: s3.IBucket;
|
|
24
|
+
renditions: RenditionsPlan;
|
|
25
|
+
renditionsCode?: Code;
|
|
26
|
+
};
|
|
27
|
+
|
|
19
28
|
export type S3ProjectionPublication = {
|
|
20
29
|
bucket: s3.Bucket;
|
|
21
30
|
keyValueStore: cloudfront.KeyValueStore;
|
|
@@ -23,6 +32,7 @@ export type S3ProjectionPublication = {
|
|
|
23
32
|
deadLetterQueue: sqs.Queue;
|
|
24
33
|
distribution?: cloudfront.Distribution;
|
|
25
34
|
distributionId?: string;
|
|
35
|
+
renditions?: RenditionsFunction;
|
|
26
36
|
environment: Record<string, string>;
|
|
27
37
|
policyStatements: PolicyStatement[];
|
|
28
38
|
};
|
|
@@ -88,7 +98,11 @@ function createDistribution(
|
|
|
88
98
|
});
|
|
89
99
|
}
|
|
90
100
|
|
|
91
|
-
export function addS3ProjectionPublication(
|
|
101
|
+
export function addS3ProjectionPublication(
|
|
102
|
+
scope: Construct,
|
|
103
|
+
props: S3ProjectionPublicationProps,
|
|
104
|
+
media?: S3ProjectionMedia,
|
|
105
|
+
): S3ProjectionPublication {
|
|
92
106
|
assertValidPublicationConfig({ kind: "s3-projection", ...props });
|
|
93
107
|
const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
|
|
94
108
|
|
|
@@ -123,6 +137,10 @@ export function addS3ProjectionPublication(scope: Construct, props: S3Projection
|
|
|
123
137
|
);
|
|
124
138
|
}
|
|
125
139
|
|
|
140
|
+
const renditions = media?.renditions.enabled === true
|
|
141
|
+
? addRenditionsFunction(scope, { siteBucket: bucket, mediaBucket: media.mediaBucket, siteFiles: media.renditions.siteFiles, code: media.renditionsCode })
|
|
142
|
+
: undefined;
|
|
143
|
+
|
|
126
144
|
const settingsInput = {
|
|
127
145
|
bucketName: bucket.bucketName,
|
|
128
146
|
bucketArn: bucket.bucketArn,
|
|
@@ -144,7 +162,11 @@ export function addS3ProjectionPublication(scope: Construct, props: S3Projection
|
|
|
144
162
|
deadLetterQueue,
|
|
145
163
|
distribution,
|
|
146
164
|
distributionId: distribution?.distributionId,
|
|
147
|
-
|
|
165
|
+
renditions,
|
|
166
|
+
environment: {
|
|
167
|
+
...s3ProjectionEnvironment(settingsInput),
|
|
168
|
+
...(renditions !== undefined ? { [RENDITIONS_FUNCTION_NAME_ENV]: renditions.function.functionName } : {}),
|
|
169
|
+
},
|
|
148
170
|
policyStatements: s3ProjectionPolicyStatements(settingsInput).map(
|
|
149
171
|
(statement) => new PolicyStatement({ effect: Effect.ALLOW, actions: statement.actions, resources: statement.resources }),
|
|
150
172
|
),
|
package/amplify/site-backend.js
CHANGED
|
@@ -8,6 +8,7 @@ import { dirname, resolve } from "node:path";
|
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves.js";
|
|
10
10
|
import { addS3ProjectionPublication } from "./publication/s3-projection.js";
|
|
11
|
+
import { planRenditions } from "./publication/renditions-settings.js";
|
|
11
12
|
import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings.js";
|
|
12
13
|
import { rebuildTriggerSettings } from "./rebuild-trigger-settings.js";
|
|
13
14
|
import { assertValidPublicationConfig } from "./site-backend-config.js";
|
|
@@ -68,6 +69,7 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
68
69
|
if (site.publication !== undefined)
|
|
69
70
|
assertValidPublicationConfig(site.publication);
|
|
70
71
|
const publicationPlan = planS3ProjectionBackend(site.publication);
|
|
72
|
+
const renditionsPlan = planRenditions(site.publication, process.cwd());
|
|
71
73
|
setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
|
|
72
74
|
const backend = defineBackend({
|
|
73
75
|
assignmentAction,
|
|
@@ -224,8 +226,8 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
224
226
|
let storageBackupPlan;
|
|
225
227
|
let storageBackupVault;
|
|
226
228
|
const storageBucketCfn = storageBucket.node.defaultChild;
|
|
227
|
-
if ((enableStorageBackups || enableInboundEmail) && storageBucketCfn) {
|
|
228
|
-
// Backups
|
|
229
|
+
if ((enableStorageBackups || enableInboundEmail || renditionsPlan.enabled) && storageBucketCfn) {
|
|
230
|
+
// Backups, inbound-email intake and upload-time renditions all rely on S3 → EventBridge notifications.
|
|
229
231
|
storageBucketCfn.addPropertyOverride("NotificationConfiguration.EventBridgeConfiguration.EventBridgeEnabled", true);
|
|
230
232
|
}
|
|
231
233
|
if (enableStorageBackups) {
|
|
@@ -311,7 +313,10 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
311
313
|
}
|
|
312
314
|
let s3ProjectionPublication;
|
|
313
315
|
if (site.publication !== undefined) {
|
|
314
|
-
s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication
|
|
316
|
+
s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication, {
|
|
317
|
+
mediaBucket: storageBucket,
|
|
318
|
+
renditions: renditionsPlan,
|
|
319
|
+
});
|
|
315
320
|
contentActionsLambda.node.defaultChild.memorySize = publicationPlan.contentActionsMemorySize;
|
|
316
321
|
}
|
|
317
322
|
const publicationWiring = contentActionsPublicationWiring(s3ProjectionPublication);
|
package/amplify/site-backend.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { dirname, resolve } from "node:path";
|
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves";
|
|
13
13
|
import { addS3ProjectionPublication, type S3ProjectionPublication } from "./publication/s3-projection";
|
|
14
|
+
import { planRenditions } from "./publication/renditions-settings";
|
|
14
15
|
import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings";
|
|
15
16
|
import { rebuildTriggerSettings } from "./rebuild-trigger-settings";
|
|
16
17
|
import { assertValidPublicationConfig } from "./site-backend-config";
|
|
@@ -85,6 +86,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
85
86
|
};
|
|
86
87
|
if (site.publication !== undefined) assertValidPublicationConfig(site.publication);
|
|
87
88
|
const publicationPlan = planS3ProjectionBackend(site.publication);
|
|
89
|
+
const renditionsPlan = planRenditions(site.publication, process.cwd());
|
|
88
90
|
setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
|
|
89
91
|
const backend = defineBackend({
|
|
90
92
|
assignmentAction,
|
|
@@ -285,8 +287,8 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
285
287
|
let storageBackupVault: backup.BackupVault | undefined;
|
|
286
288
|
|
|
287
289
|
const storageBucketCfn = storageBucket.node.defaultChild as s3.CfnBucket | undefined;
|
|
288
|
-
if ((enableStorageBackups || enableInboundEmail) && storageBucketCfn) {
|
|
289
|
-
// Backups
|
|
290
|
+
if ((enableStorageBackups || enableInboundEmail || renditionsPlan.enabled) && storageBucketCfn) {
|
|
291
|
+
// Backups, inbound-email intake and upload-time renditions all rely on S3 → EventBridge notifications.
|
|
290
292
|
storageBucketCfn.addPropertyOverride(
|
|
291
293
|
"NotificationConfiguration.EventBridgeConfiguration.EventBridgeEnabled",
|
|
292
294
|
true,
|
|
@@ -396,7 +398,10 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
396
398
|
}
|
|
397
399
|
let s3ProjectionPublication: S3ProjectionPublication | undefined;
|
|
398
400
|
if (site.publication !== undefined) {
|
|
399
|
-
s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication
|
|
401
|
+
s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication, {
|
|
402
|
+
mediaBucket: storageBucket,
|
|
403
|
+
renditions: renditionsPlan,
|
|
404
|
+
});
|
|
400
405
|
(contentActionsLambda.node.defaultChild as CfnFunction).memorySize = publicationPlan.contentActionsMemorySize;
|
|
401
406
|
}
|
|
402
407
|
const publicationWiring = contentActionsPublicationWiring(s3ProjectionPublication);
|
package/package.json
CHANGED
package/routes.manifest.json
CHANGED