diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ee5c4db --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +.git +.env +infra +*.log +.tap +.DS_Store diff --git a/.env.example b/.env.example index 5d3d08f..dd84f68 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,19 @@ # MyArtverse Backend Config MA_PORT=8081 MA_INTERFACE=0.0.0.0 + +# Frontend URL (production — Vercel) +# FRONTEND_URL=https://dev.myartverse.app +# FRONTEND_ORIGINS=https://dev.myartverse.app,https://dev.myartverse.dev + +# Frontend parts (local dev fallback) MA_FRONTEND_DOMAIN=localhost MA_FRONTEND_PORT=3000 MA_FRONTEND_HTTP=http:// + +# Cookie domain — scoped to API host (not the Vercel frontend domain) +# COOKIE_DOMAIN=.myartverse.app + MA_JWT_SECRET=JWT_SECRET MA_COOKIE_SECRET=cookienomnom MA_SESSION_SECRET=sessionnomnomsessionnomnomsessionnomnomsessionnomnom @@ -16,24 +26,27 @@ DB_PASS=postgres DB_HOST=localhost DB_PORT=5432 -# Email Config +# Email transport: smtp (local MailSlurper) or resend +EMAIL_TRANSPORT=resend + +# Resend (production) +RESEND_API_KEY= +RESEND_FROM_EMAIL=MyArtverse + +# SMTP (local dev — MailSlurper UI at http://localhost:8085) SMTP_EMAIL_HOST=127.0.0.1 SMTP_EMAIL_PORT=2500 SMTP_EMAIL_SSL=false SMTP_EMAIL_FROM=myartverse@myartverse.com -# Leave blank if using Mailslurp -SMTP_EMAIL_USER= -SMTP_EMAIL_PASS= -# AWS S3 Config -# Get these keys through MinIO from the MinIO Console (http://localhost:9000) or your cloud provider. +# AWS S3 — local dev uses MinIO; production uses IAM role (no keys, no endpoint) AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= -# These values are for local development with MinIO, change these for production deployment -AWS_DEFAULT_REGION="us-east-1" -# These values are for local development with MinIO, change these for production deployment -S3_ENDPOINT="http://localhost:9000" +AWS_DEFAULT_REGION=us-east-1 +S3_ENDPOINT=http://localhost:9000 S3_BUCKET= +# Production CDN base URL (CloudFront) +# S3_PUBLIC_URL=https://cdn.myartverse.app GOOGLE_CLIENT_ID=VALUE_GO_HERE GOOGLE_CLIENT_SECRET=VALUE_GO_HERE @@ -53,4 +66,7 @@ TIKTOK_REDIRECT_URI=http://INTERFACE:PORT/v1/auth/tiktok/callback API_BASE_URL=http://INTERFACE:PORT - +# Sentry (production error monitoring — optional locally) +# SENTRY_DSN=https://xxx@xxx.ingest.us.sentry.io/xxx +# SENTRY_ENVIRONMENT=production +# SENTRY_TRACES_SAMPLE_RATE=0.1 diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..96d32fe --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,182 @@ +# GitHub Actions + +Automated CI and deploy for the MyArtverse API. + +## Workflows + +| Workflow | Trigger | Purpose | +|----------|---------|---------| +| **CI** | PR + push to `prod` / `main` | API build + lint; CDK synth | +| **Deploy API** | Push to `prod` (app paths) or manual | Docker → ECR → EC2 | +| **Deploy Infrastructure** | Manual only | CDK stack update | + +## Setup (one time) + +### 1. GitHub secrets + +**Settings → Secrets and variables → Actions → New repository secret** + +| Secret | Description | +|--------|-------------| +| `AWS_ACCESS_KEY_ID` | IAM user access key | +| `AWS_SECRET_ACCESS_KEY` | IAM user secret key | + +### 2. IAM user + +Create user `github-actions-myartverse` in IAM. Attach **one** of the policies below. + +#### Policy A — Deploy API only (recommended) + +Use this if you only run **Deploy API** from GitHub. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ECRLogin", + "Effect": "Allow", + "Action": "ecr:GetAuthorizationToken", + "Resource": "*" + }, + { + "Sid": "ECRPush", + "Effect": "Allow", + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecr:InitiateLayerUpload", + "ecr:UploadLayerPart", + "ecr:CompleteLayerUpload", + "ecr:PutImage" + ], + "Resource": "arn:aws:ecr:us-east-2:414061810268:repository/myartverse-api" + }, + { + "Sid": "StackOutputs", + "Effect": "Allow", + "Action": "cloudformation:DescribeStacks", + "Resource": "arn:aws:cloudformation:us-east-2:414061810268:stack/MyArtverseStack/*" + }, + { + "Sid": "DeployOnEc2", + "Effect": "Allow", + "Action": [ + "ssm:SendCommand", + "ssm:GetCommandInvocation", + "ssm:ListCommandInvocations" + ], + "Resource": "*" + }, + { + "Sid": "CloudFrontCdnUrl", + "Effect": "Allow", + "Action": "cloudfront:GetDistribution", + "Resource": "arn:aws:cloudfront::414061810268:distribution/*" + } + ] +} +``` + +Replace `414061810268` with your AWS account ID if different. + +#### Policy B — Deploy Infrastructure (CDK) + +Add this **in addition to Policy A** only if you run **Deploy Infrastructure** from Actions. Prefer running CDK from your laptop with admin/SSO instead. + +```json +{ + "Sid": "CDKDeploy", + "Effect": "Allow", + "Action": [ + "cloudformation:*", + "iam:*", + "ec2:*", + "rds:*", + "s3:*", + "elasticloadbalancing:*", + "acm:*", + "secretsmanager:*", + "ecr:*", + "logs:*", + "cloudfront:*", + "ssm:*", + "route53:*" + ], + "Resource": "*", + "Condition": { + "StringEquals": { + "aws:RequestedRegion": "us-east-2" + } + } +} +``` + +### 3. App secrets (not GitHub) + +Runtime config (JWT, Sentry, Resend, OAuth) lives in **AWS Secrets Manager** (`myartverse/app/config`). Deploy API refreshes EC2 `.env` from there — no GitHub secrets needed for those. + +## Usage + +### Automatic API deploy + +Merge or push to **`prod`** when these paths change: + +- `src/**`, `Dockerfile`, `package.json`, `yarn.lock`, `infra/scripts/**`, etc. + +```bash +git push origin prod +``` + +### Manual API deploy + +**Actions → Deploy API → Run workflow** + +Optional inputs: + +- `frontend_url` — default `https://dev.myartverse.app` +- `frontend_domain` — default `dev.myartverse.app` + +### Manual infra deploy + +**Actions → Deploy Infrastructure → Run workflow** + +Optional inputs: + +- `certificate_arn` — ACM cert for HTTPS on ALB +- `api_hostname` — `api.myartverse.app` +- `cookie_domain` — `.myartverse.app` +- `frontend_domain` — `dev.myartverse.app` + +## What each workflow does + +### CI + +- `yarn build` + `yarn lint` on the API +- `cdk synth` on infra (validates CloudFormation without deploying) + +### Deploy API + +1. Configure AWS credentials from GitHub secrets +2. Run `infra/scripts/deploy-api.sh`: + - Build Docker image on the runner + - Push to ECR + - SSM command on EC2: refresh `.env`, pull image, restart container +3. Curl `/health` until OK + +### Deploy Infrastructure + +1. `npm ci` in `infra/` +2. `cdk deploy MyArtverseStack` with context from workflow inputs + +## Troubleshooting + +| Failure | Likely cause | +|---------|----------------| +| `Credentials could not be loaded` | Missing or wrong GitHub secrets | +| `AccessDenied` on `ecr:*` | IAM Policy A incomplete | +| `AccessDenied` on `ssm:*` | Missing SSM permissions | +| SSM command failed | Check EC2 instance id in stack; SSM agent running | +| Health check failed | Container crash — `docker logs myartverse-api` on EC2 | +| CI CDK synth fails | Run `npm ci` in `infra/` locally; fix TypeScript errors | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..63053e5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + pull_request: + branches: [prod, main] + push: + branches: [prod, main] + +jobs: + api: + name: API build & lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Build + run: yarn build + + - name: Lint + run: yarn lint + + infra: + name: CDK synth + runs-on: ubuntu-latest + defaults: + run: + working-directory: infra + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: infra/package-lock.json + + - name: Install CDK dependencies + run: npm ci + + - name: Synth CloudFormation template + run: npx cdk synth --quiet diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml new file mode 100644 index 0000000..7338397 --- /dev/null +++ b/.github/workflows/deploy-api.yml @@ -0,0 +1,77 @@ +name: Deploy API + +on: + push: + branches: [prod] + paths: + - "src/**" + - "Dockerfile" + - ".dockerignore" + - "package.json" + - "yarn.lock" + - "tsconfig.json" + - "infra/scripts/**" + - ".github/workflows/deploy-api.yml" + workflow_dispatch: + inputs: + frontend_url: + description: "Frontend origin for CORS and email links" + required: false + default: https://dev.myartverse.app + frontend_domain: + description: "Frontend hostname (no protocol)" + required: false + default: dev.myartverse.app + +concurrency: + group: deploy-api-${{ github.ref }} + cancel-in-progress: true + +env: + AWS_REGION: us-east-2 + FRONTEND_URL: https://dev.myartverse.app + FRONTEND_DOMAIN: dev.myartverse.app + +permissions: + contents: read + +jobs: + deploy: + name: Build, push, and deploy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy to EC2 via ECR + env: + FRONTEND_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.frontend_url || 'https://dev.myartverse.app' }} + FRONTEND_DOMAIN: ${{ github.event_name == 'workflow_dispatch' && inputs.frontend_domain || 'dev.myartverse.app' }} + run: | + chmod +x infra/scripts/deploy-api.sh infra/scripts/refresh-env.sh + ./infra/scripts/deploy-api.sh + + - name: Health check + run: | + API_URL=$(aws cloudformation describe-stacks \ + --stack-name MyArtverseStack \ + --region "$AWS_REGION" \ + --query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" \ + --output text) + echo "Checking $API_URL/health" + for i in 1 2 3 4 5; do + if curl -fsS "$API_URL/health" | grep -q '"status":"ok"'; then + echo "Health check passed" + exit 0 + fi + echo "Attempt $i failed, retrying..." + sleep 10 + done + echo "Health check failed" + exit 1 diff --git a/.github/workflows/deploy-infra.yml b/.github/workflows/deploy-infra.yml new file mode 100644 index 0000000..3bf485e --- /dev/null +++ b/.github/workflows/deploy-infra.yml @@ -0,0 +1,70 @@ +name: Deploy Infrastructure + +on: + workflow_dispatch: + inputs: + certificate_arn: + description: "ACM certificate ARN (optional, enables HTTPS on ALB)" + required: false + type: string + api_hostname: + description: "API hostname (e.g. api.myartverse.app)" + required: false + default: api.myartverse.app + cookie_domain: + description: "Cookie domain (e.g. .myartverse.app)" + required: false + default: .myartverse.app + frontend_domain: + description: "Vercel frontend hostname (e.g. dev.myartverse.app)" + required: false + default: dev.myartverse.app + +concurrency: + group: deploy-infra + cancel-in-progress: false + +env: + AWS_REGION: us-east-2 + +permissions: + contents: read + +jobs: + deploy: + name: CDK deploy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: infra/package-lock.json + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Install CDK dependencies + working-directory: infra + run: npm ci + + - name: Deploy CDK stack + working-directory: infra + run: | + ARGS="-c frontendDomain=${{ inputs.frontend_domain }}" + if [[ -n "${{ inputs.certificate_arn }}" ]]; then + ARGS="$ARGS -c certificateArn=${{ inputs.certificate_arn }}" + fi + if [[ -n "${{ inputs.api_hostname }}" ]]; then + ARGS="$ARGS -c apiHostname=${{ inputs.api_hostname }}" + fi + if [[ -n "${{ inputs.cookie_domain }}" ]]; then + ARGS="$ARGS -c cookieDomain=${{ inputs.cookie_domain }}" + fi + npx cdk deploy MyArtverseStack --require-approval never $ARGS diff --git a/.gitignore b/.gitignore index 7eac068..b70c0f6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ node_modules dist .tap /.vscode +infra/node_modules +infra/cdk.out +infra/dist diff --git a/Dockerfile b/Dockerfile index 300abdb..17e0fd7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,18 @@ -FROM node:22 +FROM node:22-alpine WORKDIR /app -COPY package*.json ./ +ARG GIT_COMMIT_SHA="" +ENV GIT_COMMIT_SHA=$GIT_COMMIT_SHA -RUN npm install +COPY package.json yarn.lock ./ + +RUN yarn install --frozen-lockfile COPY . . -EXPOSE 8081 +RUN yarn build -RUN npm run build +EXPOSE 8081 -CMD ["npm", "start"] \ No newline at end of file +CMD ["yarn", "start"] diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..0b95fb6 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,162 @@ +# MyArtverse AWS Infrastructure + +CDK stack for the **myartverse.app** API with the **Vercel** frontend at **dev.myartverse.app**. + +> **New to this stack?** Read **[AWS_GUIDE.md](./AWS_GUIDE.md)** — a full learning walkthrough with architecture diagrams, step-by-step deploy, troubleshooting, and project notes. + +## Architecture + +| Service | URL | +|---------|-----| +| Frontend (Vercel) | `https://dev.myartverse.app` | +| API (ALB + EC2) | `https://api.myartverse.app` | +| CDN (uploads) | `https://cdn.myartverse.app` | + +CORS allows `https://dev.myartverse.app`. Auth cookies are scoped to `.myartverse.app` (the API domain) and sent on cross-origin requests to `api.myartverse.app` when the frontend uses `credentials: 'include'`. + +## What gets created + +| Resource | Purpose | +|----------|---------| +| VPC `10.252.254.0/25` | Public, private-app, private-db subnets (2 AZ) | +| EC2 `t3.small` | Runs API Docker container on port 8081 | +| RDS PostgreSQL 16 | `db.t4g.micro`, encrypted, private subnet | +| S3 | Private uploads bucket | +| CloudFront | Public CDN at `cdn.myartverse.app` | +| ALB | HTTPS for `api.myartverse.app` | +| ECR | `myartverse-api` container registry | +| Secrets Manager | RDS credentials + app config | +| IAM | EC2 instance profile (S3, ECR, SSM, secrets) | + +## Prerequisites + +1. AWS CLI configured (`aws sts get-caller-identity`) +2. Node.js 20+ +3. Docker +4. Route 53 hosted zone for `myartverse.app` (recommended) + +Bootstrap CDK once per account/region: + +```bash +cd infra +npm install +npx cdk bootstrap aws://ACCOUNT_ID/us-east-2 +``` + +**Region must match:** if you bootstrap `us-east-2`, set `"region": "us-east-2"` in `cdk.json` (already set). Bootstrapping one region does not work in another. + +## Deploy infrastructure + +**No Route 53 zone yet?** Deploy without `hostedZoneId` — you get an ALB URL over HTTP, then add DNS later: + +```bash +cd infra +npm run deploy +``` + +With Route 53 (auto DNS + ACM certificates for `api.myartverse.app`): + +```bash +cd infra +npm run deploy -- -c hostedZoneId=Z0123456789ABCDEFGHIJ +``` + +Find your zone (empty result means the domain is not in Route 53 yet): + +```bash +aws route53 list-hosted-zones-by-name --dns-name myartverse.app +``` + +## Configure secrets + +After the stack deploys, update the app secret with real values: + +```bash +aws secretsmanager put-secret-value \ + --secret-id myartverse/app/config \ + --secret-string '{ + "MA_JWT_SECRET": "your-long-random-secret", + "MA_COOKIE_SECRET": "your-cookie-secret", + "MA_SESSION_SECRET": "your-session-secret", + "RESEND_API_KEY": "re_xxx", + "GOOGLE_CLIENT_ID": "xxx", + "GOOGLE_CLIENT_SECRET": "xxx", + "FACEBOOK_CLIENT_ID": "xxx", + "FACEBOOK_CLIENT_SECRET": "xxx", + "SENTRY_DSN": "https://xxx@xxx.ingest.us.sentry.io/xxx" + }' +``` + +## Build and deploy the API + +```bash +chmod +x infra/scripts/deploy-api.sh +./infra/scripts/deploy-api.sh +``` + +This builds the Docker image, pushes to ECR, and restarts the container on EC2. + +## Stack outputs + +| Output | Example | +|--------|---------| +| `ApiUrl` | `https://api.myartverse.app` | +| `CdnUrl` | `https://cdn.myartverse.app` | +| `EcrRepositoryUri` | `123456789.dkr.ecr.us-east-1.amazonaws.com/myartverse-api` | +| `Ec2InstanceId` | `i-0abc123...` | + +## OAuth & Resend + +Register these in provider consoles: + +- Google redirect: `https://api.myartverse.app/v1/auth/google/callback` +- Facebook redirect: `https://api.myartverse.app/v1/auth/facebook/callback` +- Google authorized JavaScript origin (if needed): `https://dev.myartverse.app` +- Verify `myartverse.app` in Resend for `noreply@myartverse.app` + +## Sentry (error monitoring) + +1. Create a project at [sentry.io](https://sentry.io) → **Node.js** → copy the DSN +2. Add `SENTRY_DSN` to the `myartverse/app/config` secret (see above) +3. Redeploy the API: `./infra/scripts/deploy-api.sh` + +Sentry is disabled when `SENTRY_DSN` is empty (local dev). Optional env vars: `SENTRY_ENVIRONMENT`, `SENTRY_TRACES_SAMPLE_RATE` (default `0.1`). + +## Vercel frontend + +Set in the Vercel project environment: + +```bash +NEXT_PUBLIC_API_URL=https://api.myartverse.app +``` + +All API requests from the frontend must use `credentials: 'include'` so auth cookies from `api.myartverse.app` are sent. + +## Shell access (no SSH) + +```bash +aws ssm start-session --target +``` + +## Tear down / pause + +```bash +chmod +x infra/scripts/teardown.sh + +# Stop EC2 + RDS (saves ~$30/mo; NAT + ALB still run ~$55/mo) +./infra/scripts/teardown.sh pause + +# Start again +./infra/scripts/teardown.sh resume + +# Full delete (RDS → final snapshot; S3 + ECR retained) +./infra/scripts/teardown.sh destroy +``` + +Or destroy directly with CDK: + +```bash +cd infra && npx cdk destroy MyArtverseStack --force +``` + +RDS creates a final snapshot on destroy. S3 bucket and ECR repo are **retained** — delete manually if you want zero trace. diff --git a/infra/bin/app.ts b/infra/bin/app.ts new file mode 100644 index 0000000..69538f8 --- /dev/null +++ b/infra/bin/app.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import * as cdk from "aws-cdk-lib" +import { MyArtverseStack } from "../lib/myartverse-stack" + +const app = new cdk.App() + +const domainName = app.node.tryGetContext("domainName") as string +const frontendDomain = app.node.tryGetContext("frontendDomain") as string +const apiSubdomain = app.node.tryGetContext("apiSubdomain") as string +const cdnSubdomain = app.node.tryGetContext("cdnSubdomain") as string +const vpcCidr = app.node.tryGetContext("vpcCidr") as string +const region = app.node.tryGetContext("region") as string +const hostedZoneId = app.node.tryGetContext("hostedZoneId") as string | undefined +const apiHostname = app.node.tryGetContext("apiHostname") as string | undefined +const certificateArn = app.node.tryGetContext("certificateArn") as string | undefined +const cookieDomain = app.node.tryGetContext("cookieDomain") as string | undefined + +new MyArtverseStack(app, "MyArtverseStack", { + env: { account: process.env.CDK_DEFAULT_ACCOUNT, region }, + domainName, + frontendDomain, + apiSubdomain, + cdnSubdomain, + vpcCidr, + hostedZoneId: hostedZoneId || undefined, + apiHostname: apiHostname || undefined, + certificateArn: certificateArn || undefined, + cookieDomain: cookieDomain || undefined, + description: "MyArtverse API — VPC, EC2, RDS, S3, CloudFront, ALB", +}) diff --git a/infra/cdk.context.json b/infra/cdk.context.json new file mode 100644 index 0000000..b14122b --- /dev/null +++ b/infra/cdk.context.json @@ -0,0 +1,15 @@ +{ + "availability-zones:account=414061810268:region=us-east-1": [ + "us-east-1a", + "us-east-1b", + "us-east-1c", + "us-east-1d", + "us-east-1e", + "us-east-1f" + ], + "availability-zones:account=414061810268:region=us-east-2": [ + "us-east-2a", + "us-east-2b", + "us-east-2c" + ] +} diff --git a/infra/cdk.json b/infra/cdk.json new file mode 100644 index 0000000..43b0875 --- /dev/null +++ b/infra/cdk.json @@ -0,0 +1,15 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/app.ts", + "context": { + "domainName": "myartverse.app", + "frontendDomain": "dev.myartverse.app", + "apiHostname": "api.myartverse.app", + "cookieDomain": ".myartverse.app", + "apiSubdomain": "api", + "cdnSubdomain": "cdn", + "vpcCidr": "10.252.254.0/25", + "region": "us-east-2", + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true + } +} diff --git a/infra/lib/myartverse-stack.ts b/infra/lib/myartverse-stack.ts new file mode 100644 index 0000000..5848171 --- /dev/null +++ b/infra/lib/myartverse-stack.ts @@ -0,0 +1,429 @@ +import * as cdk from "aws-cdk-lib" +import * as acm from "aws-cdk-lib/aws-certificatemanager" +import * as cloudfront from "aws-cdk-lib/aws-cloudfront" +import * as origins from "aws-cdk-lib/aws-cloudfront-origins" +import * as ec2 from "aws-cdk-lib/aws-ec2" +import * as ecr from "aws-cdk-lib/aws-ecr" +import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2" +import * as iam from "aws-cdk-lib/aws-iam" +import * as logs from "aws-cdk-lib/aws-logs" +import * as rds from "aws-cdk-lib/aws-rds" +import * as route53 from "aws-cdk-lib/aws-route53" +import * as route53targets from "aws-cdk-lib/aws-route53-targets" +import * as s3 from "aws-cdk-lib/aws-s3" +import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager" +import * as targets from "aws-cdk-lib/aws-elasticloadbalancingv2-targets" +import { Construct } from "constructs" + +export interface MyArtverseStackProps extends cdk.StackProps { + domainName: string + frontendDomain: string + apiSubdomain: string + cdnSubdomain: string + vpcCidr: string + hostedZoneId?: string + /** Full API hostname, e.g. api.myartverse.app (overrides apiSubdomain.domainName) */ + apiHostname?: string + /** Existing ACM cert ARN in the stack region (for HTTPS without Route 53) */ + certificateArn?: string + /** Cookie domain, e.g. .myartverse.app */ + cookieDomain?: string +} + +export class MyArtverseStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: MyArtverseStackProps) { + super(scope, id, props) + + const apiDomain = props.apiHostname ?? `${props.apiSubdomain}.${props.domainName}` + const cdnDomain = `${props.cdnSubdomain}.${props.domainName}` + const frontendUrl = `https://${props.frontendDomain}` + const cookieDomain = + props.cookieDomain ?? + (props.apiHostname?.includes(".") + ? `.${props.apiHostname.split(".").slice(-2).join(".")}` + : `.${props.domainName}`) + + // ── VPC (10.252.254.0/25) ────────────────────────────────────────────── + const vpc = new ec2.Vpc(this, "Vpc", { + ipAddresses: ec2.IpAddresses.cidr(props.vpcCidr), + maxAzs: 2, + natGateways: 1, + subnetConfiguration: [ + { name: "public", subnetType: ec2.SubnetType.PUBLIC, cidrMask: 28 }, + { + name: "private-app", + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + cidrMask: 28, + }, + { + name: "private-db", + subnetType: ec2.SubnetType.PRIVATE_ISOLATED, + cidrMask: 28, + }, + ], + }) + + vpc.addGatewayEndpoint("S3Endpoint", { + service: ec2.GatewayVpcEndpointAwsService.S3, + }) + + // ── S3 uploads bucket (private) ──────────────────────────────────────── + const uploadsBucket = new s3.Bucket(this, "UploadsBucket", { + bucketName: `myartverse-uploads-${this.account}`, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + encryption: s3.BucketEncryption.S3_MANAGED, + enforceSSL: true, + versioned: true, + removalPolicy: cdk.RemovalPolicy.RETAIN, + }) + + // ── CloudFront CDN for public image reads ────────────────────────────── + const oac = new cloudfront.S3OriginAccessControl(this, "UploadsOAC", { + originAccessControlName: "myartverse-uploads-oac", + }) + + const cdnCert = props.hostedZoneId + ? new acm.Certificate(this, "CdnCert", { + domainName: cdnDomain, + validation: acm.CertificateValidation.fromDns( + route53.HostedZone.fromHostedZoneAttributes(this, "CdnZoneLookup", { + hostedZoneId: props.hostedZoneId, + zoneName: props.domainName, + }) + ), + }) + : undefined + + const distribution = new cloudfront.Distribution(this, "CdnDistribution", { + defaultBehavior: { + origin: origins.S3BucketOrigin.withOriginAccessControl(uploadsBucket, { + originAccessControl: oac, + }), + viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD, + cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD, + }, + domainNames: cdnCert ? [cdnDomain] : undefined, + certificate: cdnCert, + minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021, + comment: "MyArtverse user uploads CDN", + }) + + // ── ECR ──────────────────────────────────────────────────────────────── + const repository = new ecr.Repository(this, "ApiRepository", { + repositoryName: "myartverse-api", + removalPolicy: cdk.RemovalPolicy.RETAIN, + lifecycleRules: [{ maxImageCount: 10 }], + }) + + // ── RDS PostgreSQL ───────────────────────────────────────────────────── + const dbSecurityGroup = new ec2.SecurityGroup(this, "DbSecurityGroup", { + vpc, + description: "RDS PostgreSQL for MyArtverse", + allowAllOutbound: false, + }) + + const ec2SecurityGroup = new ec2.SecurityGroup(this, "Ec2SecurityGroup", { + vpc, + description: "MyArtverse API EC2 instances", + allowAllOutbound: true, + }) + + dbSecurityGroup.addIngressRule( + ec2SecurityGroup, + ec2.Port.tcp(5432), + "PostgreSQL from API EC2" + ) + + const dbCredentials = rds.Credentials.fromGeneratedSecret("myartverse", { + secretName: "myartverse/rds/credentials", + }) + + const database = new rds.DatabaseInstance(this, "Database", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16, + }), + instanceType: ec2.InstanceType.of( + ec2.InstanceClass.T4G, + ec2.InstanceSize.MICRO + ), + vpc, + vpcSubnets: { subnetGroupName: "private-db" }, + securityGroups: [dbSecurityGroup], + credentials: dbCredentials, + databaseName: "myartverse", + allocatedStorage: 20, + maxAllocatedStorage: 100, + storageEncrypted: true, + backupRetention: cdk.Duration.days(7), + deletionProtection: false, + removalPolicy: cdk.RemovalPolicy.SNAPSHOT, + publiclyAccessible: false, + }) + + // ── App secrets (populate after deploy) ──────────────────────────────── + const appSecret = new secretsmanager.Secret(this, "AppSecret", { + secretName: "myartverse/app/config", + description: "MyArtverse API runtime configuration", + secretObjectValue: { + MA_JWT_SECRET: cdk.SecretValue.unsafePlainText("CHANGE_ME"), + MA_COOKIE_SECRET: cdk.SecretValue.unsafePlainText("CHANGE_ME"), + MA_SESSION_SECRET: cdk.SecretValue.unsafePlainText("CHANGE_ME"), + RESEND_API_KEY: cdk.SecretValue.unsafePlainText("CHANGE_ME"), + GOOGLE_CLIENT_ID: cdk.SecretValue.unsafePlainText("CHANGE_ME"), + GOOGLE_CLIENT_SECRET: cdk.SecretValue.unsafePlainText("CHANGE_ME"), + FACEBOOK_CLIENT_ID: cdk.SecretValue.unsafePlainText("CHANGE_ME"), + FACEBOOK_CLIENT_SECRET: cdk.SecretValue.unsafePlainText("CHANGE_ME"), + SENTRY_DSN: cdk.SecretValue.unsafePlainText(""), + }, + }) + + // ── EC2 IAM role ─────────────────────────────────────────────────────── + const ec2Role = new iam.Role(this, "Ec2Role", { + assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName( + "AmazonSSMManagedInstanceCore" + ), + iam.ManagedPolicy.fromAwsManagedPolicyName( + "CloudWatchAgentServerPolicy" + ), + ], + }) + + uploadsBucket.grantReadWrite(ec2Role) + appSecret.grantRead(ec2Role) + database.secret!.grantRead(ec2Role) + repository.grantPull(ec2Role) + + ec2Role.addToPrincipalPolicy( + new iam.PolicyStatement({ + actions: ["ecr:GetAuthorizationToken"], + resources: ["*"], + }) + ) + + // ── EC2 launch template + instance ───────────────────────────────────── + const userData = ec2.UserData.forLinux() + userData.addCommands( + "set -euxo pipefail", + "dnf update -y", + "dnf install -y docker jq", + "systemctl enable docker", + "systemctl start docker", + "", + `REGION="${this.region}"`, + `ACCOUNT="${this.account}"`, + `ECR_REPO="${repository.repositoryName}"`, + `RDS_SECRET_ARN="${database.secret!.secretArn}"`, + `APP_SECRET_ARN="${appSecret.secretArn}"`, + `S3_BUCKET="${uploadsBucket.bucketName}"`, + `CDN_URL="https://${cdnDomain}"`, + `API_URL="https://${apiDomain}"`, + `FRONTEND_URL="${frontendUrl}"`, + "", + "mkdir -p /opt/myartverse", + "", + "# Build .env from Secrets Manager", + "RDS_JSON=$(aws secretsmanager get-secret-value --region $REGION --secret-id $RDS_SECRET_ARN --query SecretString --output text)", + "APP_JSON=$(aws secretsmanager get-secret-value --region $REGION --secret-id $APP_SECRET_ARN --query SecretString --output text)", + "", + `jq -nr --argjson rds "$RDS_JSON" --argjson app "$APP_JSON" \\ + --arg bucket "$S3_BUCKET" --arg cdn "$CDN_URL" --arg api "$API_URL" \\ + --arg frontend "${frontendUrl}" --arg domain "${cookieDomain}" \\ + --arg frontendDomain "${props.frontendDomain}" \\ + --arg region "${this.region}" \\ + '$app + { + MA_PORT: "8081", + MA_INTERFACE: "0.0.0.0", + MA_FRONTEND_HTTP: "https://", + MA_FRONTEND_DOMAIN: $frontendDomain, + MA_FRONTEND_PORT: "443", + FRONTEND_URL: $frontend, + COOKIE_DOMAIN: $domain, + API_BASE_URL: $api, + DB_NAME: "myartverse", + DB_PORT: "5432", + DB_HOST: $rds.host, + DB_USER: $rds.username, + DB_PASS: $rds.password, + AWS_DEFAULT_REGION: $region, + S3_BUCKET: $bucket, + S3_PUBLIC_URL: $cdn, + EMAIL_TRANSPORT: "resend", + RESEND_FROM_EMAIL: "MyArtverse ", + GOOGLE_REDIRECT_URI: ($api + "/v1/auth/google/callback"), + FACEBOOK_REDIRECT_URI: ($api + "/v1/auth/facebook/callback") + } | to_entries | .[] | "\\(.key)=\\(.value)"' > /opt/myartverse/.env`, + "", + "# Pull and run API container", + "aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin $ACCOUNT.dkr.ecr.$REGION.amazonaws.com", + "docker pull $ACCOUNT.dkr.ecr.$REGION.amazonaws.com/$ECR_REPO:latest || true", + "docker rm -f myartverse-api 2>/dev/null || true", + "docker run -d --name myartverse-api --restart always --env-file /opt/myartverse/.env -p 8081:8081 $ACCOUNT.dkr.ecr.$REGION.amazonaws.com/$ECR_REPO:latest" + ) + + const apiInstance = new ec2.Instance(this, "ApiInstance", { + vpc, + vpcSubnets: { subnetGroupName: "private-app" }, + instanceType: ec2.InstanceType.of( + ec2.InstanceClass.T3, + ec2.InstanceSize.SMALL + ), + machineImage: ec2.MachineImage.latestAmazonLinux2023(), + securityGroup: ec2SecurityGroup, + role: ec2Role, + userData, + requireImdsv2: true, + blockDevices: [ + { + deviceName: "/dev/xvda", + volume: ec2.BlockDeviceVolume.ebs(30, { + encrypted: true, + volumeType: ec2.EbsDeviceVolumeType.GP3, + }), + }, + ], + associatePublicIpAddress: false, + }) + + // ── Application Load Balancer ────────────────────────────────────────── + const albSecurityGroup = new ec2.SecurityGroup(this, "AlbSecurityGroup", { + vpc, + description: "MyArtverse API ALB", + allowAllOutbound: true, + }) + albSecurityGroup.addIngressRule( + ec2.Peer.anyIpv4(), + ec2.Port.tcp(443), + "HTTPS from internet" + ) + albSecurityGroup.addIngressRule( + ec2.Peer.anyIpv4(), + ec2.Port.tcp(80), + "HTTP redirect" + ) + + ec2SecurityGroup.addIngressRule( + albSecurityGroup, + ec2.Port.tcp(8081), + "API traffic from ALB" + ) + + const alb = new elbv2.ApplicationLoadBalancer(this, "ApiAlb", { + vpc, + internetFacing: true, + securityGroup: albSecurityGroup, + vpcSubnets: { subnetGroupName: "public" }, + }) + + const apiCert = props.hostedZoneId + ? new acm.Certificate(this, "ApiCert", { + domainName: apiDomain, + validation: acm.CertificateValidation.fromDns( + route53.HostedZone.fromHostedZoneAttributes(this, "ApiZoneLookup", { + hostedZoneId: props.hostedZoneId, + zoneName: props.domainName, + }) + ), + }) + : props.certificateArn + ? acm.Certificate.fromCertificateArn( + this, + "ApiCert", + props.certificateArn + ) + : undefined + + const useHttps = Boolean(apiCert) + + const targetGroup = new elbv2.ApplicationTargetGroup(this, "ApiTargetGroup", { + vpc, + port: 8081, + protocol: elbv2.ApplicationProtocol.HTTP, + targets: [new targets.InstanceTarget(apiInstance, 8081)], + healthCheck: { + path: "/health", + healthyHttpCodes: "200", + interval: cdk.Duration.seconds(30), + }, + }) + + if (apiCert) { + alb.addListener("HttpsListener", { + port: 443, + certificates: [apiCert], + defaultAction: elbv2.ListenerAction.forward([targetGroup]), + }) + + alb.addListener("HttpListener", { + port: 80, + defaultAction: elbv2.ListenerAction.redirect({ + protocol: "HTTPS", + port: "443", + permanent: true, + }), + }) + } else { + alb.addListener("HttpListener", { + port: 80, + defaultAction: elbv2.ListenerAction.forward([targetGroup]), + }) + } + + // ── Route 53 ─────────────────────────────────────────────────────────── + if (props.hostedZoneId) { + const hostedZone = route53.HostedZone.fromHostedZoneAttributes( + this, + "HostedZone", + { hostedZoneId: props.hostedZoneId, zoneName: props.domainName } + ) + + new route53.ARecord(this, "ApiAliasRecord", { + zone: hostedZone, + recordName: props.apiSubdomain, + target: route53.RecordTarget.fromAlias( + new route53targets.LoadBalancerTarget(alb) + ), + }) + + new route53.ARecord(this, "CdnAliasRecord", { + zone: hostedZone, + recordName: props.cdnSubdomain, + target: route53.RecordTarget.fromAlias( + new route53targets.CloudFrontTarget(distribution) + ), + }) + } + + // ── CloudWatch log group ─────────────────────────────────────────────── + new logs.LogGroup(this, "ApiLogGroup", { + logGroupName: "/myartverse/api", + retention: logs.RetentionDays.ONE_MONTH, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }) + + // ── Outputs ──────────────────────────────────────────────────────────── + new cdk.CfnOutput(this, "FrontendUrl", { value: frontendUrl }) + new cdk.CfnOutput(this, "ApiUrl", { + value: useHttps ? `https://${apiDomain}` : `http://${alb.loadBalancerDnsName}`, + }) + new cdk.CfnOutput(this, "CookieDomain", { value: cookieDomain }) + new cdk.CfnOutput(this, "CdnUrl", { value: `https://${cdnDomain}` }) + new cdk.CfnOutput(this, "AlbDnsName", { value: alb.loadBalancerDnsName }) + new cdk.CfnOutput(this, "EcrRepositoryUri", { + value: repository.repositoryUri, + }) + new cdk.CfnOutput(this, "UploadsBucketName", { + value: uploadsBucket.bucketName, + }) + new cdk.CfnOutput(this, "RdsSecretArn", { + value: database.secret!.secretArn, + }) + new cdk.CfnOutput(this, "AppSecretArn", { value: appSecret.secretArn }) + new cdk.CfnOutput(this, "Ec2InstanceId", { value: apiInstance.instanceId }) + new cdk.CfnOutput(this, "CloudFrontDistributionId", { + value: distribution.distributionId, + }) + } +} diff --git a/infra/package-lock.json b/infra/package-lock.json new file mode 100644 index 0000000..184a21e --- /dev/null +++ b/infra/package-lock.json @@ -0,0 +1,489 @@ +{ + "name": "myartverse-infra", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "myartverse-infra", + "version": "1.0.0", + "dependencies": { + "aws-cdk-lib": "^2.189.0", + "constructs": "^10.4.2" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "aws-cdk": "^2.1000.0", + "typescript": "^5.8.2" + } + }, + "node_modules/@aws-cdk/asset-awscli-v1": { + "version": "2.2.282", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz", + "integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==", + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz", + "integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q==", + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/cloud-assembly-schema": { + "version": "54.4.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.4.0.tgz", + "integrity": "sha512-v04S/TkvlL+/SWw5BoBYy0XaCQCNdcgELmN2ACJqEbGxzCEzzFcTQNE8WH/6j0c+uKkOXXsdoCG0/3Z73BqzDQ==", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "^1.5.0", + "semver": "^7.8.4" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { + "version": "1.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { + "version": "7.8.4", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/aws-cdk": { + "version": "2.1128.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1128.0.tgz", + "integrity": "sha512-CXPl6Yu0gDqrS2GYDPoMCQbdJFS0tupzN8nuaTku3fewTdQJhHGK/2QpwMV/mVgsTTHB9riXT3Zt/k4trh8tCw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "cdk": "bin/cdk" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/aws-cdk-lib": { + "version": "2.260.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.260.0.tgz", + "integrity": "sha512-2PPG+hbPDot8+ibkb5Jl9y3OY5rBE6TFwjzOi+yEyU4ZG6u8bM4DDKhhBi/S20NqqSFDso9rH1txVJAdwXNiuQ==", + "bundleDependencies": [ + "@balena/dockerignore", + "@aws-cdk/cloud-assembly-api", + "case", + "fs-extra", + "ignore", + "jsonschema", + "minimatch", + "punycode", + "semver", + "table", + "yaml", + "mime-types" + ], + "license": "Apache-2.0", + "dependencies": { + "@aws-cdk/asset-awscli-v1": "2.2.282", + "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2", + "@aws-cdk/cloud-assembly-api": "^2.2.5", + "@aws-cdk/cloud-assembly-schema": "^54.0.0", + "@balena/dockerignore": "^1.0.2", + "case": "1.6.3", + "fs-extra": "^11.3.5", + "ignore": "^5.3.2", + "jsonschema": "^1.5.0", + "mime-types": "^2.1.35", + "minimatch": "^10.2.5", + "punycode": "^2.3.1", + "semver": "^7.8.1", + "table": "^6.9.0", + "yaml": "1.10.3" + }, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "constructs": "^10.5.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { + "version": "2.2.5", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "^1.5.0", + "semver": "^7.8.0" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "@aws-cdk/cloud-assembly-schema": ">=53.28.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { + "version": "1.0.2", + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/aws-cdk-lib/node_modules/ajv": { + "version": "8.20.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-regex": { + "version": "5.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-styles": { + "version": "4.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/astral-regex": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/balanced-match": { + "version": "4.0.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/aws-cdk-lib/node_modules/brace-expansion": { + "version": "5.0.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/aws-cdk-lib/node_modules/case": { + "version": "1.6.3", + "inBundle": true, + "license": "(MIT OR GPL-3.0-or-later)", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-convert": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-name": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/emoji-regex": { + "version": "8.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-deep-equal": { + "version": "3.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-uri": { + "version": "3.1.2", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "inBundle": true, + "license": "BSD-3-Clause" + }, + "node_modules/aws-cdk-lib/node_modules/fs-extra": { + "version": "11.3.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/aws-cdk-lib/node_modules/graceful-fs": { + "version": "4.2.11", + "inBundle": true, + "license": "ISC" + }, + "node_modules/aws-cdk-lib/node_modules/ignore": { + "version": "5.3.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/aws-cdk-lib/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/json-schema-traverse": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/jsonfile": { + "version": "6.2.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/jsonschema": { + "version": "1.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/lodash.truncate": { + "version": "4.4.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/mime-db": { + "version": "1.52.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/mime-types": { + "version": "2.1.35", + "inBundle": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/minimatch": { + "version": "10.2.5", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/aws-cdk-lib/node_modules/punycode": { + "version": "2.3.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/aws-cdk-lib/node_modules/require-from-string": { + "version": "2.0.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/semver": { + "version": "7.8.1", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/slice-ansi": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/string-width": { + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/strip-ansi": { + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/table": { + "version": "6.9.0", + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/universalify": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/yaml": { + "version": "1.10.3", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/constructs": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz", + "integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==", + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/infra/package.json b/infra/package.json new file mode 100644 index 0000000..36e9023 --- /dev/null +++ b/infra/package.json @@ -0,0 +1,21 @@ +{ + "name": "myartverse-infra", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "cdk": "cdk", + "deploy": "cdk deploy --require-approval never", + "diff": "cdk diff", + "synth": "cdk synth" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "aws-cdk": "^2.1000.0", + "typescript": "^5.8.2" + }, + "dependencies": { + "aws-cdk-lib": "^2.189.0", + "constructs": "^10.4.2" + } +} diff --git a/infra/scripts/deploy-api.sh b/infra/scripts/deploy-api.sh new file mode 100755 index 0000000..6ffb696 --- /dev/null +++ b/infra/scripts/deploy-api.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +REGION="${AWS_REGION:-us-east-2}" +STACK_NAME="${STACK_NAME:-MyArtverseStack}" + +stack_output() { + aws cloudformation describe-stacks \ + --stack-name "$STACK_NAME" \ + --region "$REGION" \ + --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \ + --output text +} + +echo "==> Resolving ECR repository from stack outputs..." +ECR_URI=$(stack_output EcrRepositoryUri) + +if [[ -z "$ECR_URI" || "$ECR_URI" == "None" ]]; then + echo "Error: EcrRepositoryUri not found. Deploy infra first: cd infra && npm run deploy" + exit 1 +fi + +echo "==> Logging in to ECR ($ECR_URI)..." +aws ecr get-login-password --region "$REGION" | \ + docker login --username AWS --password-stdin "${ECR_URI%%/*}" + +echo "==> Building Docker image..." +COMMIT_SHA=$(git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || echo "") +docker build --platform linux/amd64 \ + --build-arg "GIT_COMMIT_SHA=$COMMIT_SHA" \ + -t myartverse-api:latest "$ROOT_DIR" + +echo "==> Pushing to ECR..." +docker tag myartverse-api:latest "$ECR_URI:latest" +docker push "$ECR_URI:latest" + +INSTANCE_ID=$(stack_output Ec2InstanceId) +RDS_SECRET_ARN=$(stack_output RdsSecretArn) +APP_SECRET_ARN=$(stack_output AppSecretArn) +S3_BUCKET=$(stack_output UploadsBucketName) +API_URL=$(stack_output ApiUrl) +FRONTEND_URL="${FRONTEND_URL:-$(stack_output FrontendUrl)}" +FRONTEND_DOMAIN="${FRONTEND_DOMAIN:-${FRONTEND_URL#https://}}" +FRONTEND_DOMAIN="${FRONTEND_DOMAIN%%/*}" +CDN_URL=$(stack_output CdnUrl) +COOKIE_DOMAIN=$(stack_output CookieDomain) +COOKIE_DOMAIN="${COOKIE_DOMAIN:-.myartverse.app}" + +# Use the CloudFront distribution domain until custom CDN DNS (cdn.myartverse.app) is live. +CF_DIST_ID=$(stack_output CloudFrontDistributionId) +if [[ -n "$CF_DIST_ID" && "$CF_DIST_ID" != "None" ]]; then + CF_DOMAIN=$(aws cloudfront get-distribution \ + --id "$CF_DIST_ID" \ + --region "$REGION" \ + --query "Distribution.DomainName" \ + --output text 2>/dev/null || true) + if [[ -n "$CF_DOMAIN" && "$CF_DOMAIN" != "None" ]]; then + CDN_URL="${CDN_URL_OVERRIDE:-https://${CF_DOMAIN}}" + fi +fi + +REFRESH_ENV_B64=$(base64 < "$(dirname "$0")/refresh-env.sh" | tr -d '\n') + +echo "==> Refreshing .env and restarting API on EC2 ($INSTANCE_ID)..." +COMMAND_ID=$(aws ssm send-command \ + --region "$REGION" \ + --instance-ids "$INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --comment "Redeploy MyArtverse API" \ + --parameters "commands=[ + \"echo $REFRESH_ENV_B64 | base64 -d > /tmp/refresh-env.sh\", + \"chmod +x /tmp/refresh-env.sh\", + \"RDS_SECRET_ARN=$RDS_SECRET_ARN APP_SECRET_ARN=$APP_SECRET_ARN S3_BUCKET=$S3_BUCKET API_URL=$API_URL FRONTEND_URL=$FRONTEND_URL FRONTEND_DOMAIN=$FRONTEND_DOMAIN CDN_URL=$CDN_URL COOKIE_DOMAIN=$COOKIE_DOMAIN AWS_REGION=$REGION /tmp/refresh-env.sh\", + \"aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin ${ECR_URI%%/*}\", + \"docker pull $ECR_URI:latest\", + \"docker rm -f myartverse-api || true\", + \"docker run -d --name myartverse-api --restart always --env-file /opt/myartverse/.env -p 8081:8081 $ECR_URI:latest\" + ]" \ + --query Command.CommandId \ + --output text) + +echo "==> Waiting for SSM command $COMMAND_ID..." +aws ssm wait command-executed \ + --region "$REGION" \ + --command-id "$COMMAND_ID" \ + --instance-id "$INSTANCE_ID" + +STATUS=$(aws ssm get-command-invocation \ + --region "$REGION" \ + --command-id "$COMMAND_ID" \ + --instance-id "$INSTANCE_ID" \ + --query Status \ + --output text) + +if [[ "$STATUS" != "Success" ]]; then + aws ssm get-command-invocation \ + --region "$REGION" \ + --command-id "$COMMAND_ID" \ + --instance-id "$INSTANCE_ID" \ + --query StandardErrorContent \ + --output text + echo "Error: SSM deploy failed with status $STATUS" + exit 1 +fi + +echo "==> Done. API deployed successfully." diff --git a/infra/scripts/refresh-env.sh b/infra/scripts/refresh-env.sh new file mode 100755 index 0000000..a36f388 --- /dev/null +++ b/infra/scripts/refresh-env.sh @@ -0,0 +1,48 @@ + #!/usr/bin/env bash +# Regenerate /opt/myartverse/.env from Secrets Manager (run on EC2 or via SSM). +set -euo pipefail + +REGION="${AWS_REGION:-us-east-2}" +RDS_SECRET_ARN="${RDS_SECRET_ARN:?RDS_SECRET_ARN required}" +APP_SECRET_ARN="${APP_SECRET_ARN:?APP_SECRET_ARN required}" +S3_BUCKET="${S3_BUCKET:?S3_BUCKET required}" +CDN_URL="${CDN_URL:-https://cdn.myartverse.app}" +API_URL="${API_URL:?API_URL required}" +FRONTEND_URL="${FRONTEND_URL:-https://dev.myartverse.app}" +FRONTEND_DOMAIN="${FRONTEND_DOMAIN:-dev.myartverse.app}" +COOKIE_DOMAIN="${COOKIE_DOMAIN:-.myartverse.app}" + +mkdir -p /opt/myartverse + +RDS_JSON=$(aws secretsmanager get-secret-value --region "$REGION" --secret-id "$RDS_SECRET_ARN" --query SecretString --output text) +APP_JSON=$(aws secretsmanager get-secret-value --region "$REGION" --secret-id "$APP_SECRET_ARN" --query SecretString --output text) + +jq -nr --argjson rds "$RDS_JSON" --argjson app "$APP_JSON" \ + --arg bucket "$S3_BUCKET" --arg cdn "$CDN_URL" --arg api "$API_URL" \ + --arg frontend "$FRONTEND_URL" --arg domain "$COOKIE_DOMAIN" \ + --arg frontendDomain "$FRONTEND_DOMAIN" --arg region "$REGION" \ + '$app + { + NODE_ENV: "production", + MA_PORT: "8081", + MA_INTERFACE: "0.0.0.0", + MA_FRONTEND_HTTP: "https://", + MA_FRONTEND_DOMAIN: $frontendDomain, + MA_FRONTEND_PORT: "443", + FRONTEND_URL: $frontend, + COOKIE_DOMAIN: $domain, + API_BASE_URL: $api, + DB_NAME: "myartverse", + DB_PORT: "5432", + DB_HOST: $rds.host, + DB_USER: $rds.username, + DB_PASS: $rds.password, + AWS_DEFAULT_REGION: $region, + S3_BUCKET: $bucket, + S3_PUBLIC_URL: $cdn, + EMAIL_TRANSPORT: "resend", + RESEND_FROM_EMAIL: "MyArtverse ", + GOOGLE_REDIRECT_URI: ($api + "/v1/auth/google/callback"), + FACEBOOK_REDIRECT_URI: ($api + "/v1/auth/facebook/callback") + } | to_entries | .[] | "\(.key)=\(.value)"' > /opt/myartverse/.env + +echo "Wrote /opt/myartverse/.env ($(wc -l < /opt/myartverse/.env) lines)" diff --git a/infra/scripts/setup-https.sh b/infra/scripts/setup-https.sh new file mode 100755 index 0000000..1a1369f --- /dev/null +++ b/infra/scripts/setup-https.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Request an ACM certificate and print DNS records to add in Vercel (or your DNS). +set -euo pipefail + +REGION="${AWS_REGION:-us-east-2}" +DOMAIN="${1:-api.myartverse.dev}" + +echo "==> Requesting ACM certificate for $DOMAIN in $REGION..." +ARN=$(aws acm request-certificate \ + --region "$REGION" \ + --domain-name "$DOMAIN" \ + --validation-method DNS \ + --query CertificateArn \ + --output text) + +echo "Certificate ARN: $ARN" +echo "" +echo "Waiting a few seconds for validation records..." +sleep 5 + +aws acm describe-certificate \ + --region "$REGION" \ + --certificate-arn "$ARN" \ + --query 'Certificate.DomainValidationOptions[0].ResourceRecord' \ + --output table + +echo "" +echo "Add the CNAME above in Vercel → myartverse.dev → DNS." +echo "Wait until status is ISSUED:" +echo " aws acm describe-certificate --region $REGION --certificate-arn $ARN --query Certificate.Status" +echo "" +echo "Then add API CNAME in Vercel:" +ALB=$(aws cloudformation describe-stacks --stack-name MyArtverseStack --region "$REGION" \ + --query "Stacks[0].Outputs[?OutputKey=='AlbDnsName'].OutputValue" --output text) +echo " Name: api Value: $ALB (or use subdomain $DOMAIN → $ALB)" +echo "" +echo "Redeploy with HTTPS:" +echo " cd infra && npm run deploy -- -c certificateArn=$ARN" diff --git a/infra/scripts/teardown.sh b/infra/scripts/teardown.sh new file mode 100755 index 0000000..fbd3152 --- /dev/null +++ b/infra/scripts/teardown.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Pull the plug on MyArtverse AWS resources. +# +# Usage: +# ./infra/scripts/teardown.sh pause # Stop EC2 + RDS (~$30/mo savings, keeps infra) +# ./infra/scripts/teardown.sh resume # Start EC2 + RDS again +# ./infra/scripts/teardown.sh destroy # Delete the entire CDK stack +# +set -euo pipefail + +REGION="${AWS_REGION:-us-east-2}" +STACK_NAME="${STACK_NAME:-MyArtverseStack}" +ACTION="${1:-}" + +usage() { + echo "Usage: $0 {pause|resume|destroy}" + exit 1 +} + +stack_output() { + aws cloudformation describe-stacks \ + --stack-name "$STACK_NAME" \ + --region "$REGION" \ + --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \ + --output text 2>/dev/null || true +} + +rds_instance_id() { + aws cloudformation describe-stack-resources \ + --stack-name "$STACK_NAME" \ + --region "$REGION" \ + --query "StackResources[?ResourceType=='AWS::RDS::DBInstance'].PhysicalResourceId" \ + --output text 2>/dev/null || true +} + +pause_stack() { + INSTANCE_ID="$(stack_output Ec2InstanceId)" + DB_ID="$(rds_instance_id)" + + if [[ -n "$INSTANCE_ID" && "$INSTANCE_ID" != "None" ]]; then + echo "==> Stopping EC2: $INSTANCE_ID" + aws ec2 stop-instances --region "$REGION" --instance-ids "$INSTANCE_ID" + else + echo "WARN: EC2 instance not found (stack may not be deployed)" + fi + + if [[ -n "$DB_ID" && "$DB_ID" != "None" ]]; then + echo "==> Stopping RDS: $DB_ID" + aws rds stop-db-instance --region "$REGION" --db-instance-identifier "$DB_ID" || true + fi + + echo "==> Paused. NAT Gateway + ALB still bill (~\$55/mo). Use 'destroy' to remove those." +} + +resume_stack() { + INSTANCE_ID="$(stack_output Ec2InstanceId)" + DB_ID="$(rds_instance_id)" + + if [[ -n "$DB_ID" && "$DB_ID" != "None" ]]; then + echo "==> Starting RDS: $DB_ID" + aws rds start-db-instance --region "$REGION" --db-instance-identifier "$DB_ID" || true + echo " Waiting for RDS to become available..." + aws rds wait db-instance-available --region "$REGION" --db-instance-identifier "$DB_ID" + fi + + if [[ -n "$INSTANCE_ID" && "$INSTANCE_ID" != "None" ]]; then + echo "==> Starting EC2: $INSTANCE_ID" + aws ec2 start-instances --region "$REGION" --instance-ids "$INSTANCE_ID" + aws ec2 wait instance-running --region "$REGION" --instance-ids "$INSTANCE_ID" + fi + + echo "==> Resumed." +} + +destroy_stack() { + echo "==> Destroying CloudFormation stack: $STACK_NAME" + echo " RDS will create a final snapshot, then delete." + echo " S3 bucket + ECR repo are RETAINED (delete manually if needed)." + read -r -p "Type 'destroy' to confirm: " confirm + if [[ "$confirm" != "destroy" ]]; then + echo "Aborted." + exit 1 + fi + + cd "$(dirname "$0")/.." + npx cdk destroy "$STACK_NAME" --force + + echo "==> Stack destroyed." + echo " Manual cleanup (if desired):" + echo " - S3 bucket: aws s3 rb s3://myartverse-uploads-ACCOUNT_ID --force" + echo " - ECR repo: aws ecr delete-repository --repository-name myartverse-api --force" + echo " - RDS snapshots in RDS console" +} + +case "$ACTION" in + pause) pause_stack ;; + resume) resume_stack ;; + destroy) destroy_stack ;; + *) usage ;; +esac diff --git a/infra/tsconfig.json b/infra/tsconfig.json new file mode 100644 index 0000000..24b3744 --- /dev/null +++ b/infra/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["bin/**/*.ts", "lib/**/*.ts"] +} diff --git a/package.json b/package.json index a760c7a..40fa0f9 100644 --- a/package.json +++ b/package.json @@ -35,22 +35,24 @@ "@fastify/session": "^11.1.0", "@fastify/swagger": "^9.4.2", "@fastify/swagger-ui": "^5.2.2", + "@sentry/node": "^10.59.0", "@types/bcrypt": "^5.0.2", "bcryptjs": "^3.0.2", "dotenv": "^16.4.7", "fastify": "^5.2.1", "form-auto-content": "^3.2.1", - "nodemailer": "^6.10.0", + "nodemailer": "^9.0.1", "pg": "^8.13.3", "pino-pretty": "^13.0.0", "reflect-metadata": "^0.2.2", + "resend": "^6.14.0", "tap": "^21.1.0", "typeorm": "^0.3.21" }, "devDependencies": { "@stylistic/eslint-plugin": "^4.2.0", "@types/node": "^22.13.9", - "@types/nodemailer": "^6.4.17", + "@types/nodemailer": "^8.0.1", "@typescript-eslint/eslint-plugin": "^8.26.0", "@typescript-eslint/parser": "^8.26.0", "eslint": "^9.21.0", diff --git a/src/index.ts b/src/index.ts index aebb9ae..0d6365f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import * as dotenv from "dotenv" +import { initSentry, setupFastifySentry, captureException } from "./utils/sentry" +import { MAX_MULTIPART_BYTES } from "./utils/uploadLimits" + +dotenv.config() +initSentry() + import { S3Client } from "@aws-sdk/client-s3" import { fastifyCookie, type FastifyCookieOptions } from "@fastify/cookie" import fastifyCors from "@fastify/cors" @@ -6,16 +13,17 @@ import fastifyJwt from "@fastify/jwt" import multipart from "@fastify/multipart" import swagger from "@fastify/swagger" import swaggerUI from "@fastify/swagger-ui" -import * as dotenv from "dotenv" import fastify from "fastify" -import nodemailer, { SentMessageInfo } from "nodemailer" import authRoutes from "./routes/v1/Auth/routes" +import { createMailer, type Mailer } from "./utils/mailer" import { characterRoutes } from "./routes/v1/Characters/routes" import profileRoutes from "./routes/v1/Profile/routes" import { authMiddleware, optionalAuthMiddleware } from "./utils/auth" import connectDatabase from "./utils/database" import { ensureS3Bucket } from "./utils/images" import { checkModAbovePermissions } from "./utils/permission" +import { getCorsOrigins } from "./utils/config" +import { createS3Client } from "./utils/s3" import artRoutes from "./routes/v1/Art/routes" import relationshipRoutes from "./routes/v1/Relationships/routes" import StaffRoutes from "./routes/v1/Staff/routes" @@ -24,8 +32,9 @@ import fastifyOauth2, { OAuth2Namespace } from "@fastify/oauth2" import fastifySession from "@fastify/session" import folderRoutes from "./routes/v1/Folder/routes" import dashboardRoutes from "./routes/v1/Dashboard/routes" -import { DataSource } from "typeorm" +import { getGitCommitSha } from "./utils/buildInfo" import { generalRoutes } from "./routes/v1/General/routes" +import { DataSource } from "typeorm" declare module "fastify" { interface FastifyInstance { @@ -33,7 +42,7 @@ declare module "fastify" { auth: any permissionAboveMod: any optionalAuth: any - mailer: nodemailer.Transporter + mailer: Mailer s3: S3Client } @@ -51,11 +60,10 @@ declare module "fastify" { } const app = async () => { - dotenv.config() - // Initalize Database and Fastify const connection = await connectDatabase() const server = fastify({ logger: true }) + setupFastifySentry(server) // cookie server.register(fastifyCookie, { @@ -67,15 +75,7 @@ const app = async () => { }) // S3 - const s3 = new S3Client({ - endpoint: process.env.S3_ENDPOINT as string, - region: process.env.AWS_DEFAULT_REGION as string, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID as string, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY as string - }, - forcePathStyle: true, - }) + const s3 = createS3Client() server.decorate("s3", s3) await ensureS3Bucket(s3) @@ -98,15 +98,9 @@ const app = async () => { // Permission Dectorator server.decorate("permissionAboveMod", checkModAbovePermissions) - // Initialize Nodemailer - const mailer = nodemailer.createTransport({ - host: process.env.SMTP_EMAIL_HOST, - port: Number(process.env.SMTP_EMAIL_PORT), - secure: process.env.NODE_ENV === "production" ? true : false - }) - - // Mailer Decorator - server.decorate("mailer", mailer).addHook("onClose", () => mailer.close()) + // Initialize Resend mailer + const mailer = createMailer() + server.decorate("mailer", mailer) // JWT server.register(fastifyJwt, { @@ -116,23 +110,25 @@ const app = async () => { // CORS server.register(fastifyCors, { - origin: - `${process.env.MA_FRONTEND_HTTP}${process.env.MA_FRONTEND_DOMAIN}:${process.env.MA_FRONTEND_PORT}` || - "http://localhost:3000", + origin: getCorsOrigins(), credentials: true, - methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] + methods: ["GET", "POST", "PUT", "DELETE", "PATCH"], + allowedHeaders: ["Content-Type", "Authorization", "Cookie"] }) // Multer server.register(multipart, { limits: { - fileSize: 10 * 1024 * 1024 // 10MB Limit - } + fileSize: MAX_MULTIPART_BYTES, + }, }) // Health Check server.get("/health", async () => { - return { status: "ok" } + return { + status: "ok", + commit: getGitCommitSha(), + } }) // Swaggy Styff @@ -182,6 +178,7 @@ const app = async () => { }, (err, address) => { if (err) { + captureException(err) server.log.error(err) process.exit(1) } @@ -191,4 +188,8 @@ const app = async () => { ) } -app() +app().catch((error) => { + captureException(error) + console.error(error) + process.exit(1) +}) diff --git a/src/models/Artwork.ts b/src/models/Artwork.ts index 758fc0b..870d91c 100644 --- a/src/models/Artwork.ts +++ b/src/models/Artwork.ts @@ -67,7 +67,7 @@ export default class Artwork { @JoinTable() favoritedBy: User[] - @OneToOne(() => Character, (character) => character, { nullable: true }) + @ManyToOne(() => Character, { nullable: true }) @JoinColumn() publishedCharacter: Character | null @@ -75,8 +75,8 @@ export default class Artwork { @JoinColumn() owner: User - @ManyToOne(() => Folder, (folder) => folder.artworks, { onDelete: "CASCADE" }) - folder: Folder; + @ManyToOne(() => Folder, (folder) => folder.artworks, { onDelete: "SET NULL", nullable: true }) + folder: Folder | null; // Statistics @Column({ default: 0 }) diff --git a/src/models/Character.ts b/src/models/Character.ts index cba4a7a..1bde9b5 100644 --- a/src/models/Character.ts +++ b/src/models/Character.ts @@ -88,8 +88,11 @@ export default class Character { @ManyToOne(() => User, (user) => user.characters, { eager: true }) owner: User - @ManyToOne(() => Folder, (folder) => folder.characters, { onDelete: "CASCADE" }) - folder: Folder; + @ManyToOne(() => Folder, (folder) => folder.characters, { onDelete: "SET NULL", nullable: true }) + folder: Folder | null; + + @OneToMany(() => Folder, (folder) => folder.character) + galleryFolders: Folder[]; @OneToMany(() => Dashboard, (dashboard) => dashboard.character, { cascade: true }) dashboards: Dashboard[]; diff --git a/src/models/Folder.ts b/src/models/Folder.ts index 667b934..3c5fca9 100644 --- a/src/models/Folder.ts +++ b/src/models/Folder.ts @@ -29,6 +29,12 @@ export default class Folder { @ManyToOne(() => User, (user) => user.folders, { onDelete: "CASCADE" }) owner: User; + @ManyToOne(() => Character, (character) => character.galleryFolders, { nullable: true, onDelete: "CASCADE" }) + character: Character | null; + + @RelationId((folder: Folder) => folder.character) + characterId: string | null; + @OneToMany(() => Character, (character) => character.folder) characters: Character[]; diff --git a/src/models/RefSheetVarients.ts b/src/models/RefSheetVarients.ts index 7413c3d..4010c03 100644 --- a/src/models/RefSheetVarients.ts +++ b/src/models/RefSheetVarients.ts @@ -1,6 +1,5 @@ import RefSheet from "./RefSheet" -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from "typeorm" -import User from "./Users" +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from "typeorm" @Entity() export default class RefSheetVariant { diff --git a/src/models/Users.ts b/src/models/Users.ts index 513b417..4496c44 100644 --- a/src/models/Users.ts +++ b/src/models/Users.ts @@ -150,6 +150,9 @@ export default class User { @Column({ default: Role.USER }) role: Role + @Column({ type: "integer", nullable: true }) + uploadLimitBytes: number | null + @Column({ nullable: true, type: "jsonb" }) artistApplication: { name: string diff --git a/src/routes/v1/Art/controllers.ts b/src/routes/v1/Art/controllers.ts index ac9d4a0..2954c5e 100644 --- a/src/routes/v1/Art/controllers.ts +++ b/src/routes/v1/Art/controllers.ts @@ -3,6 +3,7 @@ import { Character, Commission, Image, User } from "../../../models" import Artwork from "../../../models/Artwork" import Comment from "../../../models/Comments" import Notification from "../../../models/Notifications" +import Folder from "../../../models/Folder" import { sendNotification } from "../../../utils/notification" export const uploadArt = async (request: FastifyRequest, reply: FastifyReply) => { @@ -86,8 +87,8 @@ export const getCharacterArtwork = async ( artist: true, charactersFeatured: true, comments: true, - publishedCharacter: true - + publishedCharacter: true, + folder: true, }, }, where: { slug: characterName, owner: { handle: ownerHandle } } @@ -318,6 +319,72 @@ export const updateArtwork = async (request: FastifyRequest, reply: FastifyReply return reply.code(200).send({ message: "Artwork updated" }) } +export const assignArtworkToFolder = async ( + request: FastifyRequest, + reply: FastifyReply +) => { + const { profileId } = request.user as { profileId: string } + const { artworkId, folderId } = request.params as { + artworkId: string + folderId: string + } + + const artworkRepo = request.server.db.getRepository(Artwork) + const folderRepo = request.server.db.getRepository(Folder) + + const artwork = await artworkRepo.findOne({ + where: { id: artworkId }, + relations: { + owner: true, + publishedCharacter: true, + charactersFeatured: true, + }, + }) + + if (!artwork) { + return reply.code(404).send({ error: "Artwork not found" }) + } + + if (artwork.owner?.id !== profileId) { + return reply.code(403).send({ error: "Forbidden" }) + } + + const characterId = + artwork.publishedCharacter?.id ?? artwork.charactersFeatured?.[0]?.id + + if (!characterId) { + return reply.code(400).send({ error: "Artwork is not linked to a character" }) + } + + if (folderId === "root") { + artwork.folder = null + await artworkRepo.save(artwork) + return reply.code(200).send({ message: "Artwork removed from folder" }) + } + + const folder = await folderRepo.findOne({ + where: { id: folderId }, + relations: { character: true, owner: true }, + }) + + if (!folder) { + return reply.code(404).send({ error: "Folder not found" }) + } + + if (folder.contentType !== "art" || !folder.character || folder.character.id !== characterId) { + return reply.code(400).send({ error: "Folder does not belong to this character gallery" }) + } + + if (folder.owner?.id !== profileId) { + return reply.code(403).send({ error: "Forbidden" }) + } + + artwork.folder = folder + await artworkRepo.save(artwork) + + return reply.code(200).send({ message: "Artwork folder updated" }) +} + export const deleteArtwork = async (request: FastifyRequest, reply: FastifyReply) => { const { profileId } = request.user as { profileId: string } const { artworkId } = request.params as { artworkId: string } diff --git a/src/routes/v1/Art/routes.ts b/src/routes/v1/Art/routes.ts index 4ca5e5c..6a7fcd7 100644 --- a/src/routes/v1/Art/routes.ts +++ b/src/routes/v1/Art/routes.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from "fastify" import { assignArtist, + assignArtworkToFolder, commentArtwork, deleteArtwork, featureCharacter, @@ -15,6 +16,12 @@ import { async function artRoutes(server: FastifyInstance) { server.post("/upload/:characterId", { onRequest: [server.auth] }, uploadArt) server.get("/characters/:ownerHandle/:characterName", getCharacterArtwork) + server.get("/gallery", { onRequest: [server.auth] }, getSelfArtworks) + server.put( + "/:artworkId/folder/:folderId", + { onRequest: [server.auth] }, + assignArtworkToFolder + ) server.get("/:artworkId", getArtwork) // server.get('/:artworkId/comments', getArtworkComments) server.post("/:artworkId/comment", { onRequest: [server.auth] }, commentArtwork) @@ -31,7 +38,6 @@ async function artRoutes(server: FastifyInstance) { server.put(`/:artworkId`, { onRequest: [server.auth] }, updateArtwork) server.delete(`/:artworkId`, { onRequest: [server.auth] }, deleteArtwork) server.post(`/:artworkId/assign/:artistId`, { onRequest: [server.auth] }, assignArtist) - server.get("/gallery", { onRequest: [server.auth] }, getSelfArtworks) } export default artRoutes diff --git a/src/routes/v1/Auth/controllers.ts b/src/routes/v1/Auth/controllers.ts index a6a9346..66c687a 100644 --- a/src/routes/v1/Auth/controllers.ts +++ b/src/routes/v1/Auth/controllers.ts @@ -3,7 +3,9 @@ import bcrypt from "bcryptjs" import { type FastifyReply, type FastifyRequest } from "fastify" import { Auth, User } from "../../../models" import { welcome, forgotPassword as forgot } from "../../../utils" +import { withEffectiveUploadLimit } from "../../../utils/uploadLimits" import { accessTokenOptions, refreshTokenOptions } from "../../../utils/auth" +import { getApiBaseUrl, getFrontendOrigin } from "../../../utils/config" import { OAuth2Namespace } from "@fastify/oauth2" import { providers } from "../../../config/oauth" // import { html } from "@/utils" @@ -91,18 +93,45 @@ export const login = async (request: FastifyRequest, reply: FastifyReply) => { }) } +const verifyUserByUuid = async ( + request: FastifyRequest, + uuid: string +): Promise<{ ok: true } | { ok: false; status: 400 | 404; error: string }> => { + if (!uuid || uuid.length !== 36) { + return { ok: false, status: 400, error: "Valid UUID is required" } + } + + const user = await request.server.db + .getRepository(Auth) + .findOne({ where: { verificationUUID: uuid } }) + + if (!user) { + return { ok: false, status: 404, error: "User not found" } + } + + if (!user.verified) { + user.verified = true + await request.server.db.getRepository(Auth).save(user) + } + + return { ok: true } +} + export const register = async (request: FastifyRequest, reply: FastifyReply) => { const body = request.body as { email: string password: string - username: string + username?: string + handle?: string } - if (!body.email || !body.password || !body.username) { + const username = body.username ?? body.handle + + if (!body.email || !body.password || !username) { return reply.code(400).send({ error: "Username, Email and password are required" }) } - const { email, password, username } = body + const { email, password } = body // Check if email is already in use const authCheck = await request.server.db @@ -140,22 +169,29 @@ export const register = async (request: FastifyRequest, reply: FastifyReply) => return reply.code(500).send({ error: "Error creating user" }) } + const verifyUrl = `${getApiBaseUrl()}/v1/auth/verify/${data.verificationUUID}` + let emailSent = true + try { - request.server.mailer.sendMail({ - from: process.env.SMTP_EMAIL_FROM, + await request.server.mailer.sendMail({ to: email, - html: welcome( - `${process.env.MA_FRONTEND_HTTP}${process.env.MA_FRONTEND_DOMAIN}:${process.env.MA_FRONTEND_PORT}/verify/${data.verificationUUID}` - ), + html: welcome(verifyUrl), subject: "Welcome to MyArtverse", - text: `Welcome to MyArtverse, ${username}!, Your account has been created. Please verify your email by clicking the link below: ` + text: `Welcome to MyArtverse, ${username}! Verify your email: ${verifyUrl}`, }) } catch (error) { - throw new Error(`Error sending email: ${error}`) + request.log.error({ err: error, email }, "Failed to send verification email") + emailSent = false } - // Return the token - return reply.code(201).send({ email, username }) + return reply.code(201).send({ + email, + username, + emailSent, + message: emailSent + ? "Account created. Check your email to verify before logging in." + : "Account created, but the verification email could not be sent. Try logging in later or contact support.", + }) } export const logout = async (_request: FastifyRequest, reply: FastifyReply) => { @@ -189,16 +225,16 @@ export const forgotPassword = async (request: FastifyRequest, reply: FastifyRepl await request.server.db.getRepository(Auth).save(user) try { - request.server.mailer.sendMail({ - from: process.env.SMTP_EMAIL_FROM, + await request.server.mailer.sendMail({ to: email, html: forgot( - `${process.env.MA_FRONTEND_HTTP}${process.env.MA_FRONTEND_DOMAIN}:${process.env.MA_FRONTEND_PORT}/recover/${user.forgotPasswordUUID}` + `${getFrontendOrigin()}/recover/${user.forgotPasswordUUID}` ), subject: "Reset Password", - text: `You have requested to reset your password. Please click the link below to reset your password: ` + text: "You have requested to reset your password. Please click the link in this email to reset your password.", }) } catch (error) { + request.log.error({ err: error, email }, "Failed to send password reset email") return reply.code(500).send({ error: "Error sending email" }) } return reply.code(200).send({ message: "Password reset email sent" }) @@ -296,27 +332,32 @@ export const whoami = async (request: FastifyRequest, reply: FastifyReply) => { return reply.code(401).send({ error: "Unauthorized" }) } - return reply.code(200).send({ ...data.user }) + return reply.code(200).send(withEffectiveUploadLimit(data.user)) } export const verify = async (request: FastifyRequest, reply: FastifyReply) => { const { uuid } = request.params as { uuid: string } - if (!uuid || uuid.length !== 36) { - return reply.code(400).send({ error: "Valid UUID is required" }) + const result = await verifyUserByUuid(request, uuid) + + if (!result.ok) { + return reply.code(result.status).send({ error: result.error }) } - const user = await request.server.db - .getRepository(Auth) - .findOne({ where: { verificationUUID: uuid } }) + return reply.code(200).send({ message: "User verified" }) +} - if (!user) { - return reply.code(404).send({ error: "User not found" }) - } +export const verifyEmailLink = async (request: FastifyRequest, reply: FastifyReply) => { + const { uuid } = request.params as { uuid: string } + const loginUrl = `${getFrontendOrigin()}/login` + const result = await verifyUserByUuid(request, uuid) - user.verified = true - await request.server.db.getRepository(Auth).save(user) + if (!result.ok) { + const error = + result.status === 400 ? "invalid_verification_link" : "verification_link_expired" + return reply.redirect(`${loginUrl}?error=${error}`) + } - return reply.code(200).send({ message: "User verified" }) + return reply.redirect(`${loginUrl}?verified=1`) } export const getOauthLink = async (request: FastifyRequest, reply: FastifyReply) => { @@ -431,7 +472,7 @@ export const loginWithOAuth = async (request: FastifyRequest, reply: FastifyRepl .setCookie("accessToken", accessToken, accessTokenOptions) .setCookie("refreshToken", refreshToken, refreshTokenOptions) .redirect( - `${process.env.MA_FRONTEND_HTTP}${process.env.MA_FRONTEND_DOMAIN}:${process.env.MA_FRONTEND_PORT}/@${profileData.handle}` + `${getFrontendOrigin()}/@${profileData.handle}` ) } catch (error) { console.error(`OAuth Error (${provider}):`, error) diff --git a/src/routes/v1/Auth/routes.ts b/src/routes/v1/Auth/routes.ts index b25c946..619c127 100644 --- a/src/routes/v1/Auth/routes.ts +++ b/src/routes/v1/Auth/routes.ts @@ -8,6 +8,7 @@ import { register, whoami, verify, + verifyEmailLink, recoverPassword, validate, loginWithOAuth, @@ -39,6 +40,7 @@ async function authRoutes(server: FastifyInstance) { ) server.post("/refresh-token", { schema: REFRESH_TOKEN_SCHEMA }, refreshToken) server.get("/whoami", { onRequest: [server.auth], schema: WHOAMI_SCHEMA }, whoami) + server.get("/verify/:uuid", verifyEmailLink) server.post("/verify/:uuid", { schema: VERIFY_SCHEMA }, verify) server.get('/:provider/callback', {}, loginWithOAuth) server.get('/:provider/link', {}, getOauthLink) diff --git a/src/routes/v1/Auth/schemas.ts b/src/routes/v1/Auth/schemas.ts index 47fa46d..db83201 100644 --- a/src/routes/v1/Auth/schemas.ts +++ b/src/routes/v1/Auth/schemas.ts @@ -47,10 +47,11 @@ export const REGISTER_SCHEMA: FastifySchema = { summary: "Registering a user, taking a email, username and password", body: { type: "object", - required: ["email", "username", "password"], + required: ["email", "password"], properties: { email: { type: "string" }, username: { type: "string" }, + handle: { type: "string" }, password: { type: "string" } } }, @@ -60,7 +61,9 @@ export const REGISTER_SCHEMA: FastifySchema = { description: "Successfuly Registered", properties: { email: { type: "string" }, - username: { type: "string" } + username: { type: "string" }, + emailSent: { type: "boolean" }, + message: { type: "string" } } }, 400: { diff --git a/src/routes/v1/Characters/controllers.ts b/src/routes/v1/Characters/controllers.ts index f41f23b..aeb5fab 100644 --- a/src/routes/v1/Characters/controllers.ts +++ b/src/routes/v1/Characters/controllers.ts @@ -2,14 +2,19 @@ import type { FastifyReply, FastifyRequest } from "fastify" import { ILike, type EntityManager } from "typeorm" import { Attributes, Character, Comment, RefSheet, RefSheetVariant, User } from "../../../models" import Artwork from "../../../models/Artwork" +import Folder from "../../../models/Folder" import type { CreateCharacterBody, EditCharacterBody, GetCharacterParams, - RefSheet as RefSheetType } from "../../../types/CharacterTypes" import { uploadToS3 } from "../../../utils" +import { + formatUploadLimit, + loadUserUploadLimit, + UploadLimitError, +} from "../../../utils/uploadLimits" export const getCharacters = async (request: FastifyRequest, reply: FastifyReply) => { const user = request.user as { id: string; profileId: string } @@ -50,22 +55,30 @@ export const getOwnersCharacters = async ( const data = await request.server.db.getRepository(User).findOne({ where: { handle: ownerHandle }, relations: { - characters: true - } + characters: { + folder: true, + refSheets: { + variants: true, + }, + }, + }, }) const mainCharacter = await request.server.db.getRepository(Character).findOne({ - where: { owner: { handle: ownerHandle }, mainOwner: true } + where: { owner: { handle: ownerHandle }, mainOwner: true }, + relations: { + refSheets: { + variants: true, + }, + }, }) if (mainCharacter) { - const refSheets = await request.server.db.getRepository(RefSheet).find({ - where: { character: { id: mainCharacter.id } }, - relations: { - variants: true + data?.characters?.forEach((character) => { + if (character.id === mainCharacter.id) { + character.refSheets = mainCharacter.refSheets } }) - mainCharacter.refSheets = refSheets as RefSheet[] } if (!data) return reply.status(404).send("No user found.") @@ -189,48 +202,77 @@ export const getCharacterWithOwner = async ( } export const createCharacter = async (request: FastifyRequest, reply: FastifyReply) => { - const { name, nickname, visiblility, mainCharacter, characterAvatar } = - request.body as CreateCharacterBody + const body = request.body as CreateCharacterBody + const { name, nickname, mainCharacter, characterAvatar } = body + const visibility = body.visibility ?? body.visiblility ?? "public" const user = request.user as { id: string; profileId: string } - const data = await request.server.db.getRepository(User).findOne({ - where: { id: user.profileId } - }) + try { + const owner = await request.server.db.getRepository(User).findOne({ + where: { id: user.profileId }, + relations: { mainCharacter: true }, + }) - if (!data) return reply.status(404).send("No user found.") - const safeName = name.toLowerCase().replace(/[^a-z0-9]/g, "-") + if (!owner) return reply.status(404).send({ error: "No user found." }) - const safeNameCheck = await request.server.db.getRepository(Character).findOne({ - where: { safename: safeName, owner: data } - }) + const trimmedName = name.trim() + if (!trimmedName) { + return reply.code(400).send({ error: "Character name is required." }) + } - if (safeNameCheck) { - return reply.code(400).send({ error: "Character with that name already exists." }) - } + const safeName = trimmedName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + if (!safeName) { + return reply.code(400).send({ error: "Character name must include letters or numbers." }) + } - const newCharacter = await request.server.db.getRepository(Character).save({ - name: name, - safeName: safeName, - slug: safeName, - visibility: visiblility, - nickname: nickname, - avatarUrl: characterAvatar, - owner: data - }) + const characterRepo = request.server.db.getRepository(Character) + const attributesRepo = request.server.db.getRepository(Attributes) - await request.server.db.getRepository(Attributes).save({ - character: newCharacter - }) + const safeNameCheck = await characterRepo.findOne({ + where: { safename: safeName, owner: { id: owner.id } }, + }) - if (mainCharacter) { - data.mainCharacter = null - await request.server.db.getRepository(User).save(data) - data.mainCharacter = newCharacter - await request.server.db.getRepository(User).save(data) - } + if (safeNameCheck) { + return reply.code(400).send({ error: "Character with that name already exists." }) + } - return reply.code(200).send({ character: newCharacter }) + const attributes = await attributesRepo.save(attributesRepo.create({})) + + const characterDraft = characterRepo.create({ + name: trimmedName, + safename: safeName, + slug: safeName, + visibility, + nickname: nickname?.trim() || undefined, + avatarUrl: characterAvatar || undefined, + attributes, + }) + characterDraft.owner = owner + + const newCharacter = await characterRepo.save(characterDraft) + + if (mainCharacter) { + owner.mainCharacter = newCharacter + await request.server.db.getRepository(User).save(owner) + } + + return reply.code(200).send({ + character: { + id: newCharacter.id, + name: newCharacter.name, + slug: newCharacter.slug, + safename: newCharacter.safename, + visibility: newCharacter.visibility, + nickname: newCharacter.nickname, + avatarUrl: newCharacter.avatarUrl, + mainCharacter: !!mainCharacter, + }, + }) + } catch (error) { + request.log.error({ err: error }, "createCharacter failed") + return reply.code(500).send({ error: "Internal server error" }) + } } export const uploadArtwork = async (request: FastifyRequest, reply: FastifyReply) => { @@ -240,14 +282,31 @@ export const uploadArtwork = async (request: FastifyRequest, reply: FastifyReply return reply.code(400).send({ message: "No file uploaded" }) } + const uploadLimit = await loadUserUploadLimit(request.server.db, user.profileId) + if (uploadLimit == null) { + return reply.code(404).send({ message: "User not found" }) + } + const { file, filename, mimetype } = data - const uploadResult = await uploadToS3( - request.server.s3, - file, - filename, - mimetype, - user.id - ) + + let uploadResult + try { + uploadResult = await uploadToS3( + request.server.s3, + file, + filename, + mimetype, + user.id, + uploadLimit + ) + } catch (error) { + if (error instanceof UploadLimitError) { + return reply.code(413).send({ + message: `File exceeds your upload limit of ${formatUploadLimit(error.limitBytes)}`, + }) + } + throw error + } if (!uploadResult) { return reply.code(500).send({ message: "Error uploading file" }) @@ -263,7 +322,44 @@ export const uploadArtwork = async (request: FastifyRequest, reply: FastifyReply return reply.code(200).send({ message: "Artwork uploaded", url: image.url }) } -export const updateCharacterFolder = async (_request: FastifyRequest, reply: FastifyReply) => { +export const updateCharacterFolder = async (request: FastifyRequest, reply: FastifyReply) => { + const { profileId } = request.user as { profileId: string } + const { id, folderId } = request.params as { id: string; folderId: string } + + const characterRepo = request.server.db.getRepository(Character) + const folderRepo = request.server.db.getRepository(Folder) + + const character = await characterRepo.findOne({ + where: { id, owner: { id: profileId } }, + relations: { folder: true }, + }) + + if (!character) { + return reply.code(404).send({ error: "Character not found" }) + } + + if (folderId === "root") { + character.folder = null + await characterRepo.save(character) + return reply.code(200).send({ message: "Character removed from folder" }) + } + + const folder = await folderRepo.findOne({ + where: { + id: folderId, + owner: { id: profileId }, + contentType: "characters", + }, + relations: { character: true }, + }) + + if (!folder || folder.character) { + return reply.code(404).send({ error: "Folder not found" }) + } + + character.folder = folder + await characterRepo.save(character) + return reply.code(200).send({ message: "Character folder updated" }) } @@ -500,7 +596,6 @@ export const uploadRefSheet = async (request: FastifyRequest, reply: FastifyRepl description: variant.description ?? "", url: variant.image, artistExternal: variant.artist ?? "", - artistUser: null, nsfw: variant.nsfw ?? false, main: variant.primary, colors: variant.colors, @@ -573,8 +668,10 @@ export const getFeaturedCharacters = async ( }, relations: { owner: true, - refSheets: true, - favoritedBy: true + refSheets: { + variants: true, + }, + favoritedBy: true, }, take: 10 }) @@ -589,9 +686,11 @@ export const getNewCharacters = async (request: FastifyRequest, reply: FastifyRe take: 10, relations: { owner: true, - refSheets: true, - favoritedBy: true - } + refSheets: { + variants: true, + }, + favoritedBy: true, + }, }) if (!data) return reply.status(404).send("No new characters found.") diff --git a/src/routes/v1/Characters/schema.ts b/src/routes/v1/Characters/schema.ts index 1ce42e8..d135cfd 100644 --- a/src/routes/v1/Characters/schema.ts +++ b/src/routes/v1/Characters/schema.ts @@ -114,62 +114,24 @@ export const CREATE_CHARACTER_SCHEMA: FastifySchema = { summary: "Creates a new character for the authenticated user", body: { type: "object", - required: ["name", "visibility", "nickname", "mainCharacter", "characterAvatar"], + required: ["name", "visibility", "mainCharacter", "characterAvatar"], properties: { name: { type: "string", description: "Character's name" }, - visible: { - type: "boolean", - description: "Whether the character is visible to others" + visibility: { + type: "string", + enum: ["public", "private", "followers"], + description: "Who can view this character", }, nickname: { type: "string", description: "Character's nickname" }, mainCharacter: { type: "boolean", - description: "Whether this character is the user's main character" - }, - species: { type: "string", description: "Character's species" }, - pronouns: { type: "string", description: "Character's pronouns" }, - gender: { type: "string", description: "Character's gender" }, - bio: { type: "string", description: "Character's biography" }, - refSheet: { - type: "object", - properties: { - refSheetName: { type: "string", description: "Name of the refsheet" }, - colors: { - type: "array", - items: { type: "string", description: "List of colors" } - }, - varient: { - type: "array", - items: { - type: "object", - properties: { - name: { type: "string", description: "Name of the varient" }, - url: { type: "string", description: "URL of the varient" }, - main: { - type: "boolean", - description: "Whether this is the main varient" - }, - nsfw: { type: "boolean", description: "Whether this is a NSFW varient" } - } - } - } - } + description: "Whether this character is the user's main character", }, - likes: { - type: "array", - items: { type: "string" }, - description: "List of things the character likes" + characterAvatar: { + type: ["string", "null"], + description: "Avatar image URL", }, - dislikes: { - type: "array", - items: { type: "string" }, - description: "List of things the character dislikes" - }, - is_hybrid: { - type: "boolean", - description: "Whether the character is a hybrid of species" - } - } + }, }, response: { 200: { @@ -180,19 +142,34 @@ export const CREATE_CHARACTER_SCHEMA: FastifySchema = { properties: { id: { type: "string", description: "The UUID of the created character" }, name: { type: "string" }, - visible: { type: "boolean" }, - nickname: { type: "string" }, - species: { type: "string" } - } - } + slug: { type: "string" }, + safename: { type: "string" }, + visibility: { type: "string" }, + nickname: { type: ["string", "null"] }, + avatarUrl: { type: ["string", "null"] }, + mainCharacter: { type: "boolean" }, + }, + }, + }, + description: "The created character", + }, + 400: { + type: "object", + properties: { + error: { type: "string" }, }, - description: "The created character" }, 404: { type: "object", properties: { - error: { type: "string", description: "No user found." } - } - } - } + error: { type: "string", description: "No user found." }, + }, + }, + 500: { + type: "object", + properties: { + error: { type: "string" }, + }, + }, + }, } diff --git a/src/routes/v1/Dashboard/controllers.ts b/src/routes/v1/Dashboard/controllers.ts index 85e826f..035d9ed 100644 --- a/src/routes/v1/Dashboard/controllers.ts +++ b/src/routes/v1/Dashboard/controllers.ts @@ -2,6 +2,59 @@ import { FastifyReply, FastifyRequest } from "fastify" import Dashboard from "../../../models/Dashboard" import { Character, User } from "../../../models" import CharacterDashboard from "../../../models/CharacterDashboard" +import { PANEL_COMPONENT_TYPES } from "./panelTypes" + +const ALLOWED_PANEL_TYPES = PANEL_COMPONENT_TYPES + +const CHARACTER_ONLY_PANELS = new Set(["reference_sheet"]) +const USER_ONLY_PANELS = new Set([ + "featured_character", + "popular_character", + "multiple_characters", + "multiple_galleries", + "featured_listing", + "recent_listings", + "commission_queue", +]) + +function sanitizeSettings( + component: string, + settings: Record | undefined +) { + const safe: Record = {} + const input = settings ?? {} + + if (typeof input.artworkId === "string") safe.artworkId = input.artworkId + if (typeof input.artworkIds === "string") safe.artworkIds = input.artworkIds + if (typeof input.characterSlug === "string") safe.characterSlug = input.characterSlug + if (typeof input.characterSlugs === "string") safe.characterSlugs = input.characterSlugs + if (typeof input.refSheetId === "string") safe.refSheetId = input.refSheetId + if (typeof input.folderId === "string") safe.folderId = input.folderId + if (typeof input.customTitle === "string") safe.customTitle = input.customTitle.slice(0, 120) + if (typeof input.limit === "string") safe.limit = input.limit + + if (component === "customHTML" && typeof input.html === "string") { + safe.html = input.html + } + + return safe +} + +function isAllowedPanelType(component: string, context: "user" | "character") { + if (!ALLOWED_PANEL_TYPES.includes(component as (typeof ALLOWED_PANEL_TYPES)[number])) { + return false + } + + if (context === "user" && CHARACTER_ONLY_PANELS.has(component)) { + return false + } + + if (context === "character" && USER_ONLY_PANELS.has(component)) { + return false + } + + return true +} export const getUserPanels = async (request: FastifyRequest, reply: FastifyReply) => { const { handle } = request.params as { handle: string } @@ -23,7 +76,7 @@ export const getUserPanels = async (request: FastifyRequest, reply: FastifyReply } dashboard = await request.server.db.getRepository(Dashboard).save({ user, - panels: [] + panels: [], }) } return reply.code(200).send(dashboard.panels) @@ -46,7 +99,6 @@ export const setHTMLPanel = async (request: FastifyRequest, reply: FastifyReply) } const customHTMLPanel = dashboard.panels.find((p) => p.type === "customHTML") - console.log("customHTMLPanel", customHTMLPanel) if (customHTMLPanel) { customHTMLPanel.settings.html = html @@ -55,7 +107,7 @@ export const setHTMLPanel = async (request: FastifyRequest, reply: FastifyReply) id: `panel-${Date.now()}`, type: "customHTML", position: { row: 1, col: 1 }, - settings: { html } + settings: { html }, }) } @@ -65,16 +117,17 @@ export const setHTMLPanel = async (request: FastifyRequest, reply: FastifyReply) export const setPanel = async (request: FastifyRequest, reply: FastifyReply) => { const { profileId } = request.user as { id: string; profileId: string } - const { position, component } = request.body as { + const { position, component, settings } = request.body as { position: { row: number; col: number } component: string + settings?: Record } if (!position || !component) { return reply.code(400).send({ error: "Position and component are required" }) } - if (!["comments", "information"].includes(component)) { + if (!isAllowedPanelType(component, "user")) { return reply.code(400).send({ error: "Invalid component type" }) } @@ -86,25 +139,21 @@ export const setPanel = async (request: FastifyRequest, reply: FastifyReply) => return reply.code(404).send({ error: "Dashboard not found" }) } - // Find panel at the given position - const panelIndex = dashboard.panels.findIndex((p) => p.position.row === position.row && p.position.col === position.col) + const panelIndex = dashboard.panels.findIndex( + (p) => p.position.row === position.row && p.position.col === position.col + ) + + const newPanel = { + id: `panel-${Date.now()}`, + type: component, + position, + settings: sanitizeSettings(component, settings), + } if (panelIndex !== -1) { - // Replace existing panel - dashboard.panels[panelIndex] = { - id: `panel-${Date.now()}`, - type: component, - position, - settings: {} - } + dashboard.panels[panelIndex] = newPanel } else { - // Add new panel - dashboard.panels.push({ - id: `panel-${Date.now()}`, - type: component, - position, - settings: {} - }) + dashboard.panels.push(newPanel) } await request.server.db.getRepository(Dashboard).save(dashboard) @@ -132,19 +181,19 @@ export const getCharacterPanels = async (request: FastifyRequest, reply: Fastify const character = await request.server.db.getRepository(Character).findOne({ where: { slug: characterName }, - relations: ["owner"] + relations: ["owner"], }) if (!character) return reply.code(404).send({ error: "Character not found" }) let dashboard = await request.server.db.getRepository(CharacterDashboard).findOne({ - where: { character: { slug: characterName } } + where: { character: { slug: characterName } }, }) if (!dashboard) { dashboard = await request.server.db.getRepository(CharacterDashboard).save({ character, - panels: [] + panels: [], }) } @@ -154,16 +203,17 @@ export const getCharacterPanels = async (request: FastifyRequest, reply: Fastify export const setCharacterPanel = async (request: FastifyRequest, reply: FastifyReply) => { const { profileId } = request.user as { id: string; profileId: string } const { characterName } = request.params as { characterName: string } - const { position, component } = request.body as { + const { position, component, settings } = request.body as { position: { row: number; col: number } component: string + settings?: Record } if (!characterName || !position || !component) { return reply.code(400).send({ error: "Character name, position, and component are required" }) } - if (!["comments", "information", "featured_gallery", "featured_artwork"].includes(component)) { + if (!isAllowedPanelType(component, "character")) { return reply.code(400).send({ error: "Invalid component type" }) } @@ -176,27 +226,25 @@ export const setCharacterPanel = async (request: FastifyRequest, reply: FastifyR return reply.code(403).send({ error: "You're not the owner of this character" }) let dashboard = await request.server.db.getRepository(CharacterDashboard).findOne({ - where: { character: { slug: characterName } } + where: { character: { slug: characterName } }, }) if (!dashboard) { dashboard = await request.server.db.getRepository(CharacterDashboard).save({ character, - panels: [] + panels: [], }) } - const panelIndex = dashboard.panels.findIndex((p) => - p.position.row === position.row && p.position.col === position.col + const panelIndex = dashboard.panels.findIndex( + (p) => p.position.row === position.row && p.position.col === position.col ) - console.log(position) - const newPanel = { id: `panel-${Date.now()}`, type: component, position, - settings: {} + settings: sanitizeSettings(component, settings), } if (panelIndex !== -1) { @@ -210,22 +258,35 @@ export const setCharacterPanel = async (request: FastifyRequest, reply: FastifyR } export const setCharacterHTMLPanel = async (request: FastifyRequest, reply: FastifyReply) => { - const { html, characterId } = request.body as { html: string; characterId: string } + const { profileId } = request.user as { id: string; profileId: string } + const { characterName } = request.params as { characterName: string } + const { html } = request.body as { html: string } - if (!html || !characterId) { - return reply.code(400).send({ error: "HTML content and Character ID are required" }) + if (!html || !characterName) { + return reply.code(400).send({ error: "HTML content and character name are required" }) } - const dashboard = await request.server.db - .getRepository(CharacterDashboard) - .findOne({ where: { character: { id: characterId } } }) + const character = await request.server.db.getRepository(Character).findOne({ + where: { slug: characterName, owner: { id: profileId } }, + relations: ["owner"], + }) + + if (!character) return reply.code(404).send({ error: "Character not found" }) + if (character.owner.id !== profileId) + return reply.code(403).send({ error: "You're not the owner of this character" }) + + let dashboard = await request.server.db.getRepository(CharacterDashboard).findOne({ + where: { character: { id: character.id } }, + }) if (!dashboard) { - return reply.code(404).send({ error: "Dashboard not found" }) + dashboard = await request.server.db.getRepository(CharacterDashboard).save({ + character, + panels: [], + }) } const customHTMLPanel = dashboard.panels.find((p) => p.type === "customHTML") - console.log("customHTMLPanel", customHTMLPanel) if (customHTMLPanel) { customHTMLPanel.settings.html = html @@ -234,7 +295,7 @@ export const setCharacterHTMLPanel = async (request: FastifyRequest, reply: Fast id: `panel-${Date.now()}`, type: "customHTML", position: { row: 1, col: 1 }, - settings: { html } + settings: { html }, }) } @@ -242,16 +303,15 @@ export const setCharacterHTMLPanel = async (request: FastifyRequest, reply: Fast return reply.send(dashboard) } - export const resetCharacterPanels = async (request: FastifyRequest, reply: FastifyReply) => { const { profileId } = request.user as { id: string; profileId: string } - const { characterId } = request.body as { characterId: string } + const { characterName } = request.params as { characterName: string } - if (!characterId) return reply.code(400).send({ error: "Character ID is required" }) + if (!characterName) return reply.code(400).send({ error: "Character name is required" }) const character = await request.server.db.getRepository(Character).findOne({ - where: { id: characterId }, - relations: ["owner"] + where: { slug: characterName, owner: { id: profileId } }, + relations: ["owner"], }) if (!character) return reply.code(404).send({ error: "Character not found" }) @@ -259,7 +319,7 @@ export const resetCharacterPanels = async (request: FastifyRequest, reply: Fasti return reply.code(403).send({ error: "You're not the owner of this character" }) const dashboard = await request.server.db.getRepository(CharacterDashboard).findOne({ - where: { character: { id: characterId } } + where: { character: { id: character.id } }, }) if (!dashboard) { diff --git a/src/routes/v1/Dashboard/panelTypes.ts b/src/routes/v1/Dashboard/panelTypes.ts new file mode 100644 index 0000000..3bc100e --- /dev/null +++ b/src/routes/v1/Dashboard/panelTypes.ts @@ -0,0 +1,59 @@ +export const PANEL_COMPONENT_TYPES = [ + "comments", + "information", + "featured_gallery", + "featured_artwork", + "reference_sheet", + "featured_character", + "popular_character", + "multiple_characters", + "recent_artworks", + "multiple_artworks", + "popular_artwork", + "multiple_galleries", + "featured_listing", + "recent_listings", + "commission_queue", +] as const + +export type PanelComponentType = (typeof PANEL_COMPONENT_TYPES)[number] + +export const PANEL_SETTINGS_SCHEMA = { + type: "object", + additionalProperties: false, + properties: { + html: { type: "string" }, + artworkId: { type: "string" }, + artworkIds: { type: "string" }, + characterSlug: { type: "string" }, + characterSlugs: { type: "string" }, + refSheetId: { type: "string" }, + folderId: { type: "string" }, + customTitle: { type: "string", maxLength: 120 }, + limit: { type: "string" }, + }, +} as const + +export const PANEL_POSITION_SCHEMA = { + type: "object", + additionalProperties: false, + properties: { + row: { type: "integer", minimum: 1 }, + col: { type: "integer", minimum: 1 }, + }, + required: ["row", "col"], +} as const + +export const SET_PANEL_BODY_SCHEMA = { + type: "object", + additionalProperties: false, + properties: { + position: PANEL_POSITION_SCHEMA, + component: { + type: "string", + enum: [...PANEL_COMPONENT_TYPES], + }, + settings: PANEL_SETTINGS_SCHEMA, + }, + required: ["position", "component"], +} as const diff --git a/src/routes/v1/Dashboard/routes.ts b/src/routes/v1/Dashboard/routes.ts index ee3a3da..c9681f1 100644 --- a/src/routes/v1/Dashboard/routes.ts +++ b/src/routes/v1/Dashboard/routes.ts @@ -2,6 +2,7 @@ import { type FastifyInstance } from "fastify" import { getCharacterPanels, getUserPanels, resetCharacterPanels, resetPanels, setCharacterHTMLPanel, setCharacterPanel, setHTMLPanel, setPanel } from "./controllers" import { GET_USER_DASHBOARD_PANELS_SCHEMA, + SET_CHARACTER_PANEL_SCHEMA, SET_HTML_PANEL_SCHEMA, SET_PANEL_SCHEMA } from "./schemas" @@ -21,7 +22,11 @@ async function dashboardRoutes(server: FastifyInstance) { server.post("/panels/reset", { onRequest: [server.auth] }, resetPanels) server.get("/cpanels/:characterName", getCharacterPanels) server.put("/cpanels/:characterName/html", { onRequest: [server.auth] }, setCharacterHTMLPanel) - server.post("/cpanels/:characterName", { onRequest: [server.auth] }, setCharacterPanel) + server.post( + "/cpanels/:characterName", + { onRequest: [server.auth], schema: SET_CHARACTER_PANEL_SCHEMA }, + setCharacterPanel + ) server.post("/cpanels/:characterName/reset", { onRequest: [server.auth] }, resetCharacterPanels) } diff --git a/src/routes/v1/Dashboard/schemas.ts b/src/routes/v1/Dashboard/schemas.ts index d5dc565..896874a 100644 --- a/src/routes/v1/Dashboard/schemas.ts +++ b/src/routes/v1/Dashboard/schemas.ts @@ -1,4 +1,8 @@ import { FastifySchema } from "fastify" +import { + PANEL_SETTINGS_SCHEMA, + SET_PANEL_BODY_SCHEMA, +} from "./panelTypes" export const GET_USER_DASHBOARD_PANELS_SCHEMA: FastifySchema = { summary: "Get user panels", @@ -20,12 +24,7 @@ export const GET_USER_DASHBOARD_PANELS_SCHEMA: FastifySchema = { }, required: ["row", "col"], }, - settings: { - type: "object", - properties: { - html: { type: "string" }, - } - }, + settings: PANEL_SETTINGS_SCHEMA, }, required: ["id", "type", "position", "settings"], }, @@ -90,23 +89,9 @@ export const SET_HTML_PANEL_SCHEMA: FastifySchema = { export const SET_PANEL_SCHEMA: FastifySchema = { summary: "Set a new panel", - description: "Adds a new panel to the user's dashboard", + description: "Adds or updates a panel on the user's dashboard", tags: ["Dashboard"], - body: { - type: "object", - properties: { - position: { - type: "object", - properties: { - row: { type: "integer" }, - col: { type: "integer" }, - }, - required: ["row", "col"], - }, - component: { type: "string", enum: ["comments", "information"] }, - }, - required: ["position", "component"], - }, + body: SET_PANEL_BODY_SCHEMA, response: { 200: { type: "object", @@ -138,3 +123,11 @@ export const SET_PANEL_SCHEMA: FastifySchema = { }, }, } + +export const SET_CHARACTER_PANEL_SCHEMA: FastifySchema = { + summary: "Set a character dashboard panel", + description: "Adds or updates a panel on a character dashboard", + tags: ["Dashboard"], + body: SET_PANEL_BODY_SCHEMA, + response: SET_PANEL_SCHEMA.response, +} diff --git a/src/routes/v1/Folder/controllers.ts b/src/routes/v1/Folder/controllers.ts index 2a69473..e0126ca 100644 --- a/src/routes/v1/Folder/controllers.ts +++ b/src/routes/v1/Folder/controllers.ts @@ -1,44 +1,98 @@ import { FastifyReply, FastifyRequest } from "fastify"; -import { Folder } from "../../../models/Folder"; +import Character from "../../../models/Character"; +import Folder from "../../../models/Folder"; import { User } from "../../../models"; +const normalizeContentType = (contentType: string) => { + if (contentType === "artworks") return "art"; + return contentType; +}; + +const buildFolderTree = (folders: Folder[]) => { + const roots = folders.filter((folder) => !folder.parentId); + const attachChildren = (folder: Folder): Folder => ({ + ...folder, + children: folders + .filter((child) => child.parentId === folder.id) + .map(attachChildren), + }); + return roots.map(attachChildren); +}; + export const createFolder = async (request: FastifyRequest, reply: FastifyReply) => { - const { name, contentType, parentId, color } = request.body as { name: string, contentType: string, parentId?: string, color?: string }; - const { profileId } = request.user as { id: string; profileId: string } + const { name, contentType, parentId, color, characterId } = request.body as { + name: string; + contentType: string; + parentId?: string; + color?: string; + characterId?: string; + }; + const { profileId } = request.user as { id: string; profileId: string }; + const normalizedContentType = normalizeContentType(contentType); + if (!name || !contentType || !profileId) { - console.log("Missing required fields", { name, contentType, profileId }); return reply.status(400).send({ error: "Missing required fields" }); } - // Validate contentType const validContentTypes = ["characters", "art"]; - if (!validContentTypes.includes(contentType)) { - console.log("Invalid content type", { contentType }); + if (!validContentTypes.includes(normalizedContentType)) { return reply.status(400).send({ error: "Invalid content type" }); } - const folderRepo = await request.server.db.getRepository(Folder); - const userRepo = await request.server.db.getRepository(User); + if (normalizedContentType === "art" && !characterId) { + return reply.status(400).send({ error: "characterId is required for art folders" }); + } + + const folderRepo = request.server.db.getRepository(Folder); + const userRepo = request.server.db.getRepository(User); + const characterRepo = request.server.db.getRepository(Character); const user = await userRepo.findOne({ where: { id: profileId } }); if (!user) return reply.status(400).send({ error: "User not found" }); - const parent = parentId - ? await folderRepo.findOne({ where: { id: parentId } }) - : null; + let character: Character | null = null; + if (normalizedContentType === "art") { + character = await characterRepo.findOne({ + where: { id: characterId, owner: { id: profileId } }, + }); + if (!character) { + return reply.status(403).send({ error: "Character not found or forbidden" }); + } + } - const newFolder = await folderRepo.save({ - parent: parent, - name: name, - color: color, - contentType: contentType, + let parent: Folder | null = null; + if (parentId) { + parent = await folderRepo.findOne({ + where: { id: parentId }, + relations: { character: true }, + }); + if (!parent) { + return reply.status(404).send({ error: "Parent folder not found" }); + } + if (parent.contentType !== normalizedContentType) { + return reply.status(400).send({ error: "Parent folder content type mismatch" }); + } + if (normalizedContentType === "art") { + if (!parent.character || parent.character.id !== characterId) { + return reply.status(400).send({ error: "Parent folder belongs to a different character" }); + } + } else if (parent.character) { + return reply.status(400).send({ error: "Invalid parent folder for character folders" }); + } + } + + const newFolder = await folderRepo.save({ + parent, + name, + color, + contentType: normalizedContentType, owner: user, - children: [] - }); - await folderRepo.save(newFolder); + character: normalizedContentType === "art" ? character : null, + children: [], + }); - reply.send(newFolder); -} + return reply.send(newFolder); +}; export const getFolders = async (request: FastifyRequest, reply: FastifyReply) => { const { folderId } = request.params as { folderId: string }; @@ -52,8 +106,8 @@ export const getFolders = async (request: FastifyRequest, reply: FastifyReply) = if (!folder) return reply.status(404).send({ error: "Folder not found" }); - reply.send(folder); -} + return reply.send(folder); +}; export const getFolderByHandle = async (request: FastifyRequest, reply: FastifyReply) => { const { handle } = request.params as { handle: string }; @@ -63,37 +117,74 @@ export const getFolderByHandle = async (request: FastifyRequest, reply: FastifyR const folderRepo = request.server.db.getRepository(Folder); const folders = await folderRepo.find({ - where: { owner: { id: user.id } }, + where: { owner: { id: user.id }, contentType: "characters" }, relations: ["children", "characters", "artworks"], }); - if (!folders || folders.length === 0) return reply.status(404).send({ error: "No folders found for this user" }); + if (!folders || folders.length === 0) { + return reply.send([]); + } + + return reply.send(buildFolderTree(folders)); +}; + +export const getCharacterGalleryFolders = async ( + request: FastifyRequest, + reply: FastifyReply +) => { + const { characterId } = request.params as { characterId: string }; + if (!characterId) return reply.status(400).send({ error: "Missing characterId" }); + + const characterRepo = request.server.db.getRepository(Character); + const folderRepo = request.server.db.getRepository(Folder); + + const character = await characterRepo.findOne({ + where: { id: characterId }, + relations: { owner: true }, + }); + + if (!character) { + return reply.status(404).send({ error: "Character not found" }); + } - reply.send(folders); -} + const authUser = request.user as { profileId: string } | undefined; + const isOwner = authUser?.profileId === character.owner.id; -// const getFolderRecursively = async (request: FastifyRequest, reply: FastifyReply) => { -// const { folderId } = request.params as { folderId: string }; -// const folderRepo = request.server.db.getRepository(Folder); + if (character.visibility === "private" && !isOwner) { + return reply.status(403).send({ error: "Forbidden" }); + } -// const folder = await folderRepo.findOne({ -// where: { id: folderId }, -// relations: ["children", "characters", "artworks"], -// }); + const folders = await folderRepo.find({ + where: { + contentType: "art", + character: { id: characterId }, + }, + relations: ["children"], + order: { name: "ASC" }, + }); -// if (!folder) return reply.status(404).send({ error: "Folder not found" }); + return reply.send(buildFolderTree(folders)); +}; -// const nestedFolder = await getNestedFolders(folder, folderRepo); -// reply.send(nestedFolder); -// } +export const deleteFolder = async (request: FastifyRequest, reply: FastifyReply) => { + const { folderId } = request.params as { folderId: string }; + const { profileId } = request.user as { profileId: string }; -const getNestedFolders = async (folder: Folder, folderRepo: any) => { - const children = await folderRepo.find({ where: { parent: folder }, relations: ["children"] }); - folder.children = children; + if (!folderId) { + return reply.status(400).send({ error: "Missing folderId" }); + } + + const folderRepo = request.server.db.getRepository(Folder); - for (const child of children) { - await getNestedFolders(child, folderRepo); // Recursive call for each child + const folder = await folderRepo.findOne({ + where: { id: folderId, owner: { id: profileId } }, + }); + + if (!folder) { + return reply.status(404).send({ error: "Folder not found" }); } - return folder; -}; \ No newline at end of file + await folderRepo.remove(folder); + + return reply.send({ message: "Folder deleted" }); +}; diff --git a/src/routes/v1/Folder/routes.ts b/src/routes/v1/Folder/routes.ts index f6a9b86..61105c4 100644 --- a/src/routes/v1/Folder/routes.ts +++ b/src/routes/v1/Folder/routes.ts @@ -1,28 +1,47 @@ import type { FastifyInstance } from "fastify" -import { createFolder, getFolderByHandle, getFolders } from "./controllers" -import { CREATE_FOLDER_SCHEMA, GET_FOLDER_SCHEMA, GET_FOLDER_BY_HANDLE_SCHEMA, GET_FOLDER_RECURSIVELY_SCHEMA } from "./schemas" +import { + createFolder, + deleteFolder, + getCharacterGalleryFolders, + getFolderByHandle, + getFolders, +} from "./controllers" +import { + CREATE_FOLDER_SCHEMA, + DELETE_FOLDER_SCHEMA, + GET_CHARACTER_GALLERY_FOLDERS_SCHEMA, + GET_FOLDER_BY_HANDLE_SCHEMA, + GET_FOLDER_SCHEMA, + GET_FOLDER_RECURSIVELY_SCHEMA, +} from "./schemas" async function folderRoutes(server: FastifyInstance) { - server.post( - "/create", - { onRequest: [server.auth], schema: CREATE_FOLDER_SCHEMA }, - createFolder - ) - server.get( - "/:folderId", - { schema: GET_FOLDER_SCHEMA }, - getFolders - ) - server.get( - "/handle/:handle", - { schema: GET_FOLDER_BY_HANDLE_SCHEMA }, - getFolderByHandle - ) - server.get( - "/folders/:folderId/recursive", - { schema: GET_FOLDER_RECURSIVELY_SCHEMA }, - getFolders - ) + server.post( + "/create", + { onRequest: [server.auth], schema: CREATE_FOLDER_SCHEMA }, + createFolder + ) + server.delete( + "/:folderId", + { onRequest: [server.auth], schema: DELETE_FOLDER_SCHEMA }, + deleteFolder + ) + server.get( + "/character/:characterId", + { schema: GET_CHARACTER_GALLERY_FOLDERS_SCHEMA }, + getCharacterGalleryFolders + ) + server.get("/:folderId", { schema: GET_FOLDER_SCHEMA }, getFolders) + server.get( + "/handle/:handle", + { schema: GET_FOLDER_BY_HANDLE_SCHEMA }, + getFolderByHandle + ) + server.get( + "/folders/:folderId/recursive", + { schema: GET_FOLDER_RECURSIVELY_SCHEMA }, + getFolders + ) } export default folderRoutes diff --git a/src/routes/v1/Folder/schemas.ts b/src/routes/v1/Folder/schemas.ts index ae531c9..c7f05e5 100644 --- a/src/routes/v1/Folder/schemas.ts +++ b/src/routes/v1/Folder/schemas.ts @@ -9,9 +9,10 @@ export const CREATE_FOLDER_SCHEMA: FastifySchema = { required: ["name", "contentType"], properties: { name: { type: "string" }, - contentType: { type: "string", enum: ["characters", "art"] }, + contentType: { type: "string", enum: ["characters", "art", "artworks"] }, parentId: { type: "string", nullable: true }, - color: { type: "string", nullable: true } + color: { type: "string", nullable: true }, + characterId: { type: "string", nullable: true } } }, response: { @@ -159,4 +160,68 @@ export const GET_FOLDER_RECURSIVELY_SCHEMA: FastifySchema = { } } } +}; + +export const GET_CHARACTER_GALLERY_FOLDERS_SCHEMA: FastifySchema = { + description: "Get gallery folders for a character", + tags: ["Folder"], + summary: "Retrieve art folders scoped to a character gallery", + params: { + type: "object", + required: ["characterId"], + properties: { + characterId: { type: "string" } + } + }, + response: { + 200: { + type: "array", + items: { type: "object", additionalProperties: true } + }, + 403: { + type: "object", + properties: { + error: { type: "string" } + } + }, + 404: { + type: "object", + properties: { + error: { type: "string" } + } + } + } +}; + +export const DELETE_FOLDER_SCHEMA: FastifySchema = { + description: "Delete a folder", + tags: ["Folder"], + summary: "Delete a folder owned by the authenticated user", + params: { + type: "object", + required: ["folderId"], + properties: { + folderId: { type: "string" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + message: { type: "string" }, + }, + }, + 400: { + type: "object", + properties: { + error: { type: "string" }, + }, + }, + 404: { + type: "object", + properties: { + error: { type: "string" }, + }, + }, + }, }; \ No newline at end of file diff --git a/src/routes/v1/Profile/controllers.ts b/src/routes/v1/Profile/controllers.ts index 37948ef..77c6d90 100644 --- a/src/routes/v1/Profile/controllers.ts +++ b/src/routes/v1/Profile/controllers.ts @@ -2,6 +2,12 @@ import type { FastifyReply, FastifyRequest } from "fastify" import { Character, Image, User } from "../../../models" import { Comment } from "../../../models" import { uploadToS3 } from "../../../utils" +import { + formatUploadLimit, + loadUserUploadLimit, + UploadLimitError, + withEffectiveUploadLimit, +} from "../../../utils/uploadLimits" import { DataSource, ILike, IsNull } from "typeorm" import { sendMassNotification, sendNotification } from "../../../utils/notification" import { CommissionStatus, Role } from "../../../models/Users" @@ -46,7 +52,7 @@ export const me = async (request: FastifyRequest, reply: FastifyReply) => { } if (!userData.characters) userData.characters = [] - return reply.code(200).send({ ...userData }) + return reply.code(200).send(withEffectiveUploadLimit(userData)) } export const updateProfile = async (request: FastifyRequest, reply: FastifyReply) => { @@ -258,13 +264,30 @@ export const upload = async (request: FastifyRequest, reply: FastifyReply) => { return reply.code(400).send({ error: "No file uploaded" }) } - const result = await uploadToS3( - request.server.s3, - file, - filename, - mimetype, - user.profileId - ) + const uploadLimit = await loadUserUploadLimit(request.server.db, user.profileId) + if (uploadLimit == null) { + return reply.code(404).send({ error: "User not found" }) + } + + let result + try { + result = await uploadToS3( + request.server.s3, + file, + filename, + mimetype, + user.profileId, + uploadLimit + ) + } catch (error) { + if (error instanceof UploadLimitError) { + return reply.code(413).send({ + error: `File exceeds your upload limit of ${formatUploadLimit(error.limitBytes)}`, + }) + } + request.log.error({ err: error }, "S3 upload failed") + return reply.code(500).send({ error: "Error uploading file to storage" }) + } if (!result) { return reply.code(500).send({ error: "Error uploading" }) @@ -297,8 +320,11 @@ export const getFavorites = async (request: FastifyRequest, reply: FastifyReply) } }, relations: { - owner: true - } + owner: true, + refSheets: { + variants: true, + }, + }, }) return reply.code(200).send(characters) diff --git a/src/routes/v1/Staff/controllers.ts b/src/routes/v1/Staff/controllers.ts index e5c7cdf..9e57f38 100644 --- a/src/routes/v1/Staff/controllers.ts +++ b/src/routes/v1/Staff/controllers.ts @@ -2,6 +2,11 @@ import type { FastifyReply, FastifyRequest } from "fastify" import { User } from "../../../models" import { IsNull, Not } from "typeorm" import { sendNotification } from "../../../utils/notification" +import { + formatUploadLimit, + MAX_MULTIPART_BYTES, + withEffectiveUploadLimit, +} from "../../../utils/uploadLimits" export const promoteUserToArtist = async (request: FastifyRequest, reply: FastifyReply) => { const { userId } = request.params as { userId: string } @@ -54,4 +59,40 @@ export const getRecentlyApprovedArtists = async (request: FastifyRequest, reply: if (!artists) return reply.code(404).send({ error: "No artists found" }) return reply.code(200).send(artists) +} + +export const setUserUploadLimit = async ( + request: FastifyRequest, + reply: FastifyReply +) => { + const { userId } = request.params as { userId: string } + const { uploadLimitBytes } = request.body as { uploadLimitBytes: number | null } + + if ( + uploadLimitBytes != null && + (typeof uploadLimitBytes !== "number" || + !Number.isFinite(uploadLimitBytes) || + uploadLimitBytes <= 0 || + uploadLimitBytes > MAX_MULTIPART_BYTES) + ) { + return reply.code(400).send({ + error: `uploadLimitBytes must be between 1 and ${MAX_MULTIPART_BYTES}, or null to clear`, + }) + } + + const user = await request.server.db.getRepository(User).findOne({ + where: { id: userId }, + }) + + if (!user) return reply.code(404).send({ error: "User not found" }) + + user.uploadLimitBytes = uploadLimitBytes + await request.server.db.getRepository(User).save(user) + + return reply.code(200).send({ + message: uploadLimitBytes + ? `Upload limit set to ${formatUploadLimit(uploadLimitBytes)}` + : "Custom upload limit cleared; role default applies", + user: withEffectiveUploadLimit(user), + }) } \ No newline at end of file diff --git a/src/routes/v1/Staff/routes.ts b/src/routes/v1/Staff/routes.ts index 80b8395..9e2df7c 100644 --- a/src/routes/v1/Staff/routes.ts +++ b/src/routes/v1/Staff/routes.ts @@ -1,9 +1,14 @@ import { type FastifyInstance } from "fastify" -import { demoteUserToArtist, getArtistRequests, getRecentlyApprovedArtists, promoteUserToArtist } from "./controllers" +import { demoteUserToArtist, getArtistRequests, getRecentlyApprovedArtists, promoteUserToArtist, setUserUploadLimit } from "./controllers" async function StaffRoutes(server: FastifyInstance) { server.put("/promote/:userId/artist", { onRequest: [server.auth, server.permissionAboveMod] }, promoteUserToArtist) server.put("/demote/:userId/artist", { onRequest: [server.auth, server.permissionAboveMod] }, demoteUserToArtist) + server.put( + "/users/:userId/upload-limit", + { onRequest: [server.auth, server.permissionAboveMod] }, + setUserUploadLimit + ) server.get('/recently-approved-artists', { onRequest: [server.auth, server.permissionAboveMod] }, getRecentlyApprovedArtists) server.get('/artist-requests', { onRequest: [server.auth, server.permissionAboveMod] }, getArtistRequests) } diff --git a/src/types/CharacterTypes.ts b/src/types/CharacterTypes.ts index 5751d4d..9a775a4 100644 --- a/src/types/CharacterTypes.ts +++ b/src/types/CharacterTypes.ts @@ -6,10 +6,12 @@ interface GetCharacterParams { interface CreateCharacterBody { name: string - nickname: string - visiblility: "public" | "private" | "followers" + nickname?: string + visibility: "public" | "private" | "followers" + /** @deprecated typo — use visibility */ + visiblility?: "public" | "private" | "followers" mainCharacter: boolean - characterAvatar: string + characterAvatar: string | null } interface EditCharacterBody { diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 5ba4a84..88bb963 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1,12 +1,13 @@ import type { FastifyReply, FastifyRequest } from "fastify" import { Auth } from "../models" import { CookieSerializeOptions } from "@fastify/cookie" +import { getCookieDomain } from "./config" export const accessTokenOptions: CookieSerializeOptions = { httpOnly: true, path: "/", sameSite: "none", - domain: "localhost", + domain: getCookieDomain(), secure: true } @@ -14,28 +15,74 @@ export const refreshTokenOptions: CookieSerializeOptions = { httpOnly: true, path: "/", sameSite: "none", - domain: "localhost", + domain: getCookieDomain(), secure: true } +/** Cookie jar from @fastify/cookie, or Cookie header (Next.js server actions). */ +const getAccessToken = (request: FastifyRequest): string | undefined => { + const authorization = request.headers.authorization + if (authorization?.startsWith("Bearer ")) { + return authorization.slice("Bearer ".length).trim() + } + + if (request.cookies.accessToken) { + return request.cookies.accessToken + } + + const cookieHeader = request.headers.cookie + if (!cookieHeader) { + return undefined + } + + const match = cookieHeader.match(/(?:^|;\s*)accessToken=([^;]+)/) + return match?.[1] ? decodeURIComponent(match[1]) : undefined +} + +const getRefreshToken = (request: FastifyRequest): string | undefined => { + if (request.cookies.refreshToken) { + return request.cookies.refreshToken + } + + const cookieHeader = request.headers.cookie + if (!cookieHeader) { + return undefined + } + + const match = cookieHeader.match(/(?:^|;\s*)refreshToken=([^;]+)/) + return match?.[1] ? decodeURIComponent(match[1]) : undefined +} + +const authenticateWithAccessToken = async ( + request: FastifyRequest, + accessToken: string +): Promise => { + const payload = (await request.server.jwt.verify(accessToken)) as { id: string } + const user = await request.server.db.getRepository(Auth).findOne({ + where: { id: payload.id }, + relations: { user: true } + }) + + if (!user) { + return false + } + + request.user = { id: payload.id, profileId: user.user.id } + return true +} + export async function authMiddleware(request: FastifyRequest, reply: FastifyReply) { - const accessToken = request.cookies.accessToken + const accessToken = getAccessToken(request) if (!accessToken) { return await refreshTokenLogic(request, reply, true) } try { - const payload = (await request.server.jwt.verify(accessToken)) as { id: string } - const user = await request.server.db.getRepository(Auth).findOne({ - where: { id: payload.id }, - relations: { user: true } - }) - - if (!user) { + const authenticated = await authenticateWithAccessToken(request, accessToken) + if (!authenticated) { return await refreshTokenLogic(request, reply, true) } - request.user = { id: payload.id, profileId: user.user.id } return true } catch (error) { return await refreshTokenLogic(request, reply, true) @@ -43,21 +90,12 @@ export async function authMiddleware(request: FastifyRequest, reply: FastifyRepl } export async function optionalAuthMiddleware(request: FastifyRequest, reply: FastifyReply) { - const accessToken = request.cookies.accessToken + const accessToken = getAccessToken(request) let authenticated = false if (accessToken) { try { - const payload = (await request.server.jwt.verify(accessToken)) as { id: string } - const user = await request.server.db.getRepository(Auth).findOne({ - where: { id: payload.id }, - relations: { user: true } - }) - - if (user) { - request.user = { id: payload.id, profileId: user.user.id } - authenticated = true - } + authenticated = await authenticateWithAccessToken(request, accessToken) } catch (error) {} } @@ -67,7 +105,7 @@ export async function optionalAuthMiddleware(request: FastifyRequest, reply: Fas } async function refreshTokenLogic(request: FastifyRequest, reply: FastifyReply, force401 = false) { - const refreshToken = request.cookies.refreshToken + const refreshToken = getRefreshToken(request) if (!refreshToken) return force401 ? reply.code(401).send({ error: "Unauthorized" }) : null try { diff --git a/src/utils/buildInfo.ts b/src/utils/buildInfo.ts new file mode 100644 index 0000000..f7cd587 --- /dev/null +++ b/src/utils/buildInfo.ts @@ -0,0 +1,4 @@ +export function getGitCommitSha(): string | null { + const sha = process.env.GIT_COMMIT_SHA?.trim() + return sha || null +} diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..82505b4 --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,58 @@ +/** Shared URL helpers for local dev and production. */ + +/** Comma-separated extra origins for CORS during migration (optional). */ +export const getCorsOrigins = (): string | string[] => { + if (process.env.FRONTEND_ORIGINS) { + return process.env.FRONTEND_ORIGINS.split(",") + .map((origin) => origin.trim()) + .filter(Boolean) + } + + return getFrontendOrigin() +} + +export const getFrontendOrigin = (): string => { + if (process.env.FRONTEND_URL) { + return process.env.FRONTEND_URL.replace(/\/$/, "") + } + + const http = process.env.MA_FRONTEND_HTTP ?? "http://" + const domain = process.env.MA_FRONTEND_DOMAIN ?? "localhost" + const port = process.env.MA_FRONTEND_PORT ?? "3000" + + if (port === "443" && http.startsWith("https")) { + return `${http}${domain}` + } + if (port === "80" && http === "http://") { + return `${http}${domain}` + } + + return `${http}${domain}:${port}` +} + +export const getCookieDomain = (): string => { + return process.env.COOKIE_DOMAIN ?? "localhost" +} + +export const getApiBaseUrl = (): string => { + if (process.env.API_BASE_URL) { + return process.env.API_BASE_URL.replace(/\/$/, "") + } + + const http = process.env.MA_API_HTTP ?? "http://" + const domain = process.env.MA_API_DOMAIN ?? "localhost" + const port = process.env.MA_API_PORT ?? "8081" + + if (port === "443" && http.startsWith("https")) { + return `${http}${domain}` + } + if (port === "80" && http === "http://") { + return `${http}${domain}` + } + + return `${http}${domain}:${port}` +} + +export const isLocalS3 = (): boolean => { + return Boolean(process.env.S3_ENDPOINT) +} diff --git a/src/utils/database.ts b/src/utils/database.ts index 31aa154..8f09070 100644 --- a/src/utils/database.ts +++ b/src/utils/database.ts @@ -22,13 +22,17 @@ import CharacterDashboard from "../models/CharacterDashboard" * Connects to the database */ const connectDatabase = async (): Promise => { + const host = process.env.DB_HOST + const useSsl = host && host !== "localhost" && host !== "postgres" + const connection = new DataSource({ type: "postgres", - host: process.env.DB_HOST, + host, port: Number(process.env.DB_PORT), username: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, + ...(useSsl ? { ssl: { rejectUnauthorized: false } } : {}), entities: [ AdoptionStatus, Artwork, diff --git a/src/utils/images.ts b/src/utils/images.ts index a6c9a29..b4e8765 100644 --- a/src/utils/images.ts +++ b/src/utils/images.ts @@ -11,8 +11,14 @@ import * as fs from "fs" import os from "os" import path from "path" import { pipeline } from "stream/promises" +import { isLocalS3 } from "./config" +import { UploadLimitError } from "./uploadLimits" export const ensureS3Bucket = async (client: S3Client) => { + if (!isLocalS3()) { + return + } + const bucket = process.env.S3_BUCKET if (!bucket) { console.warn("S3_BUCKET is not configured, skipping bucket setup") @@ -71,7 +77,8 @@ export const uploadToS3 = async ( file: BusboyFileStream, key: string, mimetype: string, - userID: string + userID: string, + maxBytes?: number ) => { const bucket = process.env.S3_BUCKET if (!bucket) { @@ -87,6 +94,11 @@ export const uploadToS3 = async ( await pipeline(file, fs.createWriteStream(tempFilePath)) const { size: length } = fs.statSync(tempFilePath) + + if (maxBytes != null && length > maxBytes) { + throw new UploadLimitError(maxBytes) + } + fileStream = fs.createReadStream(tempFilePath) const command = new PutObjectCommand({ @@ -95,14 +107,18 @@ export const uploadToS3 = async ( Body: fileStream, ContentType: mimetype, ContentLength: length, - ACL: "public-read" + ...(isLocalS3() ? { ACL: "public-read" } : {}) }) const result = await client.send(command) + const publicBase = + process.env.S3_PUBLIC_URL?.replace(/\/$/, "") ?? + `${process.env.S3_ENDPOINT}/${bucket}` + return { ...result, - url: `${process.env.S3_ENDPOINT}/${bucket}/${storageKey}` + url: `${publicBase}/${storageKey}` } } finally { fileStream?.destroy() diff --git a/src/utils/index.ts b/src/utils/index.ts index 01db1e3..4423d6e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,5 @@ -import verifyToken from "./auth" import connectDatabase from "./database" import { uploadToS3 } from "./images" import { forgotPassword, welcome } from "./mail" -export { verifyToken, connectDatabase, uploadToS3, welcome, forgotPassword } +export { connectDatabase, uploadToS3, welcome, forgotPassword } diff --git a/src/utils/mailer.ts b/src/utils/mailer.ts new file mode 100644 index 0000000..ce4eadc --- /dev/null +++ b/src/utils/mailer.ts @@ -0,0 +1,146 @@ +import nodemailer from "nodemailer" +import { Resend } from "resend" + +export type MailPayload = { + from?: string + to: string + subject: string + html: string + text?: string +} + +export type Mailer = { + sendMail: (payload: MailPayload) => Promise<{ id?: string }> +} + +type MailTransport = "smtp" | "resend" + +function isLocalSmtpHost(): boolean { + const host = process.env.SMTP_EMAIL_HOST?.toLowerCase() + return host === "127.0.0.1" || host === "localhost" +} + +function resolveTransport(): MailTransport { + const configured = process.env.EMAIL_TRANSPORT?.toLowerCase() + + // Local MailSlurper is the default when configured — Resend sandbox only + // delivers to the account owner's verified address. + if (isLocalSmtpHost() && configured !== "resend") { + return "smtp" + } + + if (configured === "smtp" || configured === "resend") { + return configured + } + + if (process.env.RESEND_API_KEY) { + return "resend" + } + + return "smtp" +} + +function createSmtpMailer(): Mailer { + const defaultFrom = + process.env.SMTP_EMAIL_FROM ?? + process.env.RESEND_FROM_EMAIL ?? + "myartverse@myartverse.com" + + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_EMAIL_HOST ?? "127.0.0.1", + port: Number(process.env.SMTP_EMAIL_PORT ?? 2500), + secure: process.env.SMTP_EMAIL_SSL === "true", + auth: + process.env.SMTP_EMAIL_USER && process.env.SMTP_EMAIL_PASS + ? { + user: process.env.SMTP_EMAIL_USER, + pass: process.env.SMTP_EMAIL_PASS, + } + : undefined, + }) + + return { + sendMail: async ({ from, to, subject, html, text }) => { + const info = await transporter.sendMail({ + from: from ?? defaultFrom, + to, + subject, + html, + text, + }) + + return { id: info.messageId } + }, + } +} + +function createResendMailer(): Mailer { + const apiKey = process.env.RESEND_API_KEY + + if (!apiKey) { + throw new Error("RESEND_API_KEY is not configured") + } + + const resend = new Resend(apiKey) + const defaultFrom = + process.env.RESEND_FROM_EMAIL ?? + process.env.SMTP_EMAIL_FROM ?? + "MyArtverse " + + return { + sendMail: async ({ from, to, subject, html, text }) => { + const { data, error } = await resend.emails.send({ + from: from ?? defaultFrom, + to: [to], + subject, + html, + text, + }) + + if (error) { + throw new Error(error.message) + } + + return { id: data?.id } + }, + } +} + +export function createMailer(): Mailer { + const transport = resolveTransport() + console.log(`Email transport: ${transport}`) + + if (transport === "smtp") { + return createSmtpMailer() + } + + const resendMailer = createResendMailer() + + if (!isLocalSmtpHost()) { + return resendMailer + } + + const smtpMailer = createSmtpMailer() + + return { + sendMail: async (payload) => { + try { + return await resendMailer.sendMail(payload) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const isResendSandboxLimit = + message.includes("only send testing emails") || + message.includes("verify a domain at resend.com") + + if (!isResendSandboxLimit) { + throw error + } + + console.warn( + `Resend sandbox blocked delivery to ${payload.to}; falling back to local SMTP (MailSlurper).` + ) + return smtpMailer.sendMail(payload) + } + }, + } +} diff --git a/src/utils/s3.ts b/src/utils/s3.ts new file mode 100644 index 0000000..4fb9f58 --- /dev/null +++ b/src/utils/s3.ts @@ -0,0 +1,18 @@ +import { S3Client, type S3ClientConfig } from "@aws-sdk/client-s3" +import { isLocalS3 } from "./config" + +export const createS3Client = (): S3Client => { + const region = process.env.AWS_DEFAULT_REGION ?? "us-east-1" + const config: S3ClientConfig = { region } + + if (isLocalS3()) { + config.endpoint = process.env.S3_ENDPOINT + config.forcePathStyle = true + config.credentials = { + accessKeyId: process.env.AWS_ACCESS_KEY_ID as string, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY as string, + } + } + + return new S3Client(config) +} diff --git a/src/utils/sentry.ts b/src/utils/sentry.ts new file mode 100644 index 0000000..7cc0b02 --- /dev/null +++ b/src/utils/sentry.ts @@ -0,0 +1,39 @@ +import * as Sentry from "@sentry/node" +import type { FastifyInstance } from "fastify" + +export const isSentryEnabled = (): boolean => Boolean(process.env.SENTRY_DSN) + +export const initSentry = (): void => { + if (!isSentryEnabled()) { + console.log("Sentry disabled (SENTRY_DSN not set)") + return + } + + Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: + process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? "development", + tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.1"), + sendDefaultPii: false, + }) + + console.log( + `Sentry enabled (environment: ${process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? "development"})` + ) +} + +export const setupFastifySentry = (server: FastifyInstance): void => { + if (!isSentryEnabled()) { + return + } + + Sentry.setupFastifyErrorHandler(server) +} + +export const captureException = (error: unknown): void => { + if (!isSentryEnabled()) { + return + } + + Sentry.captureException(error) +} diff --git a/src/utils/uploadLimits.ts b/src/utils/uploadLimits.ts new file mode 100644 index 0000000..3cb3c59 --- /dev/null +++ b/src/utils/uploadLimits.ts @@ -0,0 +1,79 @@ +import type { DataSource } from "typeorm" +import User, { Role } from "../models/Users" + +/** Hard ceiling for multipart parsing (must be >= highest role/user limit). */ +export const MAX_MULTIPART_BYTES = 100 * 1024 * 1024 + +export const ROLE_UPLOAD_LIMIT_BYTES: Record = { + [Role.USER]: 10 * 1024 * 1024, + [Role.MODERATOR]: 25 * 1024 * 1024, + [Role.ADMIN]: 50 * 1024 * 1024, + [Role.DEVELOPER]: 100 * 1024 * 1024, +} + +/** Extra allowance for verified artists on the default user role. */ +export const ARTIST_UPLOAD_BONUS_BYTES = 5 * 1024 * 1024 + +export class UploadLimitError extends Error { + readonly limitBytes: number + + constructor(limitBytes: number) { + super(`File exceeds upload limit of ${formatUploadLimit(limitBytes)}`) + this.name = "UploadLimitError" + this.limitBytes = limitBytes + } +} + +export function getRoleUploadLimitBytes(role: Role): number { + return ROLE_UPLOAD_LIMIT_BYTES[role] ?? ROLE_UPLOAD_LIMIT_BYTES[Role.USER] +} + +export type UploadLimitUser = Pick + +export function getUploadLimitBytes(user: UploadLimitUser): number { + if (user.uploadLimitBytes != null && user.uploadLimitBytes > 0) { + return Math.min(user.uploadLimitBytes, MAX_MULTIPART_BYTES) + } + + let limit = getRoleUploadLimitBytes(user.role) + + if (user.hasArtistAccess && user.role === Role.USER) { + limit += ARTIST_UPLOAD_BONUS_BYTES + } + + return Math.min(limit, MAX_MULTIPART_BYTES) +} + +export function formatUploadLimit(bytes: number): string { + if (bytes >= 1024 * 1024) { + const mb = bytes / (1024 * 1024) + return Number.isInteger(mb) ? `${mb}MB` : `${mb.toFixed(1)}MB` + } + + return `${Math.round(bytes / 1024)}KB` +} + +export async function loadUserUploadLimit( + db: DataSource, + profileId: string +): Promise { + const user = await db.getRepository(User).findOne({ + where: { id: profileId }, + select: { + id: true, + role: true, + uploadLimitBytes: true, + hasArtistAccess: true, + }, + }) + + if (!user) return null + return getUploadLimitBytes(user) +} + +export function withEffectiveUploadLimit(user: T) { + return { + ...user, + effectiveUploadLimitBytes: getUploadLimitBytes(user), + } +} diff --git a/yarn.lock b/yarn.lock index 8b9fc63..a785b22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15,6 +15,37 @@ ansi-styles "^6.2.1" is-fullwidth-code-point "^4.0.0" +"@apm-js-collab/code-transformer-bundler-plugins@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.5.0.tgz#8a08136e8281f7e8e36ac6810d2b122c5917de93" + integrity sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ== + dependencies: + "@apm-js-collab/code-transformer" "^0.15.0" + es-module-lexer "^2.1.0" + magic-string "^0.30.21" + module-details-from-path "^1.0.4" + +"@apm-js-collab/code-transformer@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.15.0.tgz#a3a1b6c7b92db16f8277636b4a72a1626e2fa52a" + integrity sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww== + dependencies: + "@types/estree" "^1.0.8" + astring "^1.9.0" + esquery "^1.7.0" + meriyah "^6.1.4" + semifies "^1.0.0" + source-map "^0.6.0" + +"@apm-js-collab/tracing-hooks@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.10.0.tgz#b31f4bd474380475dc72f57f1b84dd71ba98edde" + integrity sha512-2/Z3NTewJTruUkmsSnBC5bJlLNUd9keuD1OLlTEpim4FyLhm6m2Rnfv+wrFdUvFfhmH8CRdiDZBqBrn+wyaGuA== + dependencies: + "@apm-js-collab/code-transformer" "^0.15.0" + debug "^4.4.1" + module-details-from-path "^1.0.4" + "@aws-crypto/crc32@5.2.0": version "5.2.0" resolved "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz" @@ -915,6 +946,11 @@ resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== +"@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + "@jridgewell/trace-mapping@0.3.9": version "0.3.9" resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz" @@ -1039,11 +1075,117 @@ proc-log "^4.0.0" which "^4.0.0" +"@opentelemetry/api-logs@0.214.0": + version "0.214.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz#74a54ad7b166c6fa30a0df811954c0f5a435deee" + integrity sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA== + dependencies: + "@opentelemetry/api" "^1.3.0" + +"@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.1.tgz#c1b0346de336ba55af2d5a7970882037baedec05" + integrity sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q== + +"@opentelemetry/core@2.8.0", "@opentelemetry/core@^2.6.1": + version "2.8.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.8.0.tgz#f6e86de3688bdb54a6ca8f4935363a5b588ae91c" + integrity sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww== + dependencies: + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/instrumentation@^0.214.0": + version "0.214.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz#2649e8a29a8c4748bc583d35281c80632f046e25" + integrity sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w== + dependencies: + "@opentelemetry/api-logs" "0.214.0" + import-in-the-middle "^3.0.0" + require-in-the-middle "^8.0.0" + +"@opentelemetry/resources@2.8.0": + version "2.8.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.8.0.tgz#9bcb658ab6254f33099f4a95544b40d6f53cc946" + integrity sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg== + dependencies: + "@opentelemetry/core" "2.8.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/sdk-trace-base@^2.6.1": + version "2.8.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz#ec9c1d69e2e6fba256c9df0c8e8d67d42386d52b" + integrity sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ== + dependencies: + "@opentelemetry/core" "2.8.0" + "@opentelemetry/resources" "2.8.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.40.0": + version "1.41.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz#b04e7151c5913a7a006d4f465479da75efb98a7a" + integrity sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA== + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@sentry/conventions@^0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@sentry/conventions/-/conventions-0.12.0.tgz#e963cf928ac4d1585b1dcc196e767df3ac45ca5c" + integrity sha512-z1JQrl/1SLY+8wpzvork6vl+fpsg/oCCxM7HWWhUnI/R+OGNyoIzieQuggX3uUMY7NBtp8UWCQx6FeFazzOF9g== + +"@sentry/core@10.59.0": + version "10.59.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.59.0.tgz#96aa9328c21888f3e9262cf3191274b576a62cda" + integrity sha512-QeG7XZL5j6CkToYCE7OwCerb/r742Tjj9p1BBohBKcypYTPRuqfD+A3FeUj7pk5CGO6Vj1/gOAmdbuuNbR51dQ== + +"@sentry/node-core@10.59.0": + version "10.59.0" + resolved "https://registry.yarnpkg.com/@sentry/node-core/-/node-core-10.59.0.tgz#06585d0ed2829dc6dd7d78817a38a3a7a9eb2d3e" + integrity sha512-qFbepzntYhDleNG9ZCZWCSoAJK0Nsx+UJxsuiygaaAf1rJMj95RVckLyslhY86pyDLVATNMmWm2elm6etgKaJw== + dependencies: + "@sentry/conventions" "^0.12.0" + "@sentry/core" "10.59.0" + "@sentry/opentelemetry" "10.59.0" + import-in-the-middle "^3.0.0" + +"@sentry/node@^10.59.0": + version "10.59.0" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-10.59.0.tgz#57138686ae34d6ef2f950ef7f48e76937af4874b" + integrity sha512-qzqbP6OVoMijlDBUxWtbvVF5j73+vyzGFi+yFIslhVvzBj97TFkIeP3TpBLsmu/0L5ZvxpQCCEmzJ677tFkq/g== + dependencies: + "@opentelemetry/api" "^1.9.1" + "@opentelemetry/core" "^2.6.1" + "@opentelemetry/instrumentation" "^0.214.0" + "@opentelemetry/sdk-trace-base" "^2.6.1" + "@opentelemetry/semantic-conventions" "^1.40.0" + "@sentry/core" "10.59.0" + "@sentry/node-core" "10.59.0" + "@sentry/opentelemetry" "10.59.0" + "@sentry/server-utils" "10.59.0" + import-in-the-middle "^3.0.0" + +"@sentry/opentelemetry@10.59.0": + version "10.59.0" + resolved "https://registry.yarnpkg.com/@sentry/opentelemetry/-/opentelemetry-10.59.0.tgz#d05bce127e5383ce0361fd77474de36fec601f09" + integrity sha512-wV9/HR9btrNhSkJC2S0urqsD9pE4K0f6AmdfTK3qhH505mLoyV4ekTG66hdDR9xD2zOYCm58CNzaK+336zu3Gg== + dependencies: + "@sentry/conventions" "^0.12.0" + "@sentry/core" "10.59.0" + +"@sentry/server-utils@10.59.0": + version "10.59.0" + resolved "https://registry.yarnpkg.com/@sentry/server-utils/-/server-utils-10.59.0.tgz#1e928d9340f10709168f31b6803cdf0809f439d9" + integrity sha512-mR3fWaU7uGxIstRba6YO+/6V3qIa7432F7/U8EWHry+dY4C9DWAVG90E2GCzeD2MwLSP0tB25i8p1TWTGiQgVg== + dependencies: + "@apm-js-collab/code-transformer" "^0.15.0" + "@apm-js-collab/code-transformer-bundler-plugins" "^0.5.0" + "@apm-js-collab/tracing-hooks" "^0.10.0" + "@sentry/conventions" "^0.12.0" + "@sentry/core" "10.59.0" + magic-string "~0.30.0" + "@sideway/address@^4.1.5": version "4.1.5" resolved "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz" @@ -1600,6 +1742,11 @@ resolved "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz" integrity sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw== +"@stablelib/base64@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@stablelib/base64/-/base64-1.0.1.tgz#bdfc1c6d3a62d7a3b7bbc65b6cce1bb4561641be" + integrity sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ== + "@stylistic/eslint-plugin@^4.2.0": version "4.2.0" resolved "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-4.2.0.tgz" @@ -1929,6 +2076,11 @@ resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz" integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== +"@types/estree@^1.0.8": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" + integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== + "@types/istanbul-lib-coverage@^2.0.1": version "2.0.6" resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz" @@ -1946,10 +2098,10 @@ dependencies: undici-types "~6.20.0" -"@types/nodemailer@^6.4.17": - version "6.4.17" - resolved "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz" - integrity sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww== +"@types/nodemailer@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-8.0.1.tgz#4ef2b4e62c819225a0b8a6384bf750c0c664d465" + integrity sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw== dependencies: "@types/node" "*" @@ -2054,6 +2206,11 @@ abstract-logging@^2.0.1: resolved "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz" integrity sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA== +acorn-import-attributes@^1.9.5: + version "1.9.5" + resolved "https://registry.yarnpkg.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz#7eb1557b1ba05ef18b5ed0ec67591bfab04688ef" + integrity sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ== + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" @@ -2069,6 +2226,11 @@ acorn@^8.14.0, acorn@^8.4.1: resolved "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz" integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== +acorn@^8.15.0: + version "8.17.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.17.0.tgz#1785adb84faf8d8add10369b93826fc2bd08f1fe" + integrity sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg== + agent-base@^7.0.2, agent-base@^7.1.0: version "7.1.0" resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz" @@ -2188,6 +2350,11 @@ asn1.js@^5.4.1: minimalistic-assert "^1.0.0" safer-buffer "^2.1.0" +astring@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/astring/-/astring-1.9.0.tgz#cc73e6062a7eb03e7d19c22d8b0b3451fd9bfeef" + integrity sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg== + async-hook-domain@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/async-hook-domain/-/async-hook-domain-4.0.1.tgz" @@ -2366,6 +2533,11 @@ chownr@^2.0.0: resolved "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz" integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== +cjs-module-lexer@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz#b3ca5101843389259ade7d88c77bd06ce55849ca" + integrity sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ== + clean-stack@^2.0.0: version "2.2.0" resolved "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz" @@ -2501,6 +2673,13 @@ debug@4, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.0: dependencies: ms "^2.1.3" +debug@^4.3.5, debug@^4.4.1: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + deep-is@^0.1.3: version "0.1.4" resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz" @@ -2599,6 +2778,11 @@ err-code@^2.0.2: resolved "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz" integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA== +es-module-lexer@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz#1dfcbb5ea3bbfb63f28e1fc3676c3676d1c9624c" + integrity sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ== + es-toolkit@^1.22.0: version "1.32.0" resolved "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.32.0.tgz" @@ -2703,6 +2887,13 @@ esquery@^1.5.0: dependencies: estraverse "^5.1.0" +esquery@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + dependencies: + estraverse "^5.1.0" + esrecurse@^4.3.0: version "4.3.0" resolved "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz" @@ -2825,6 +3016,11 @@ fast-safe-stringify@^2.1.1: resolved "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz" integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== +fast-sha256@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/fast-sha256/-/fast-sha256-1.3.0.tgz#7916ba2054eeb255982608cccd0f6660c79b7ae6" + integrity sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ== + fast-uri@^3.0.0, fast-uri@^3.0.1, fast-uri@^3.0.5: version "3.0.6" resolved "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz" @@ -3195,6 +3391,16 @@ import-fresh@^3.2.1: parent-module "^1.0.0" resolve-from "^4.0.0" +import-in-the-middle@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-3.2.0.tgz#ef596b83cdef975940239b2f04d874585910197a" + integrity sha512-vR2B6HKIhaBjcZr2bLpFiJ1VbzOlRQ7aby4/gw5WPIzToLjqpfWw3VJ4sk1uDchoOODEirvO2jyrSPtUSL5CrQ== + dependencies: + acorn "^8.15.0" + acorn-import-attributes "^1.9.5" + cjs-module-lexer "^2.2.0" + module-details-from-path "^1.0.4" + imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz" @@ -3577,6 +3783,13 @@ lru-cache@^11.0.0: resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz" integrity sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA== +magic-string@^0.30.21, magic-string@~0.30.0: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + make-dir@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz" @@ -3616,6 +3829,11 @@ merge2@^1.3.0: resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== +meriyah@^6.1.4: + version "6.1.4" + resolved "https://registry.yarnpkg.com/meriyah/-/meriyah-6.1.4.tgz#2d49a8934fbcd9205c20564579c3560d9b1e077b" + integrity sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ== + micromatch@^4.0.8: version "4.0.8" resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz" @@ -3775,6 +3993,11 @@ mnemonist@^0.39.8: dependencies: obliterator "^2.0.1" +module-details-from-path@^1.0.3, module-details-from-path@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.4.tgz#b662fdcd93f6c83d3f25289da0ce81c8d9685b94" + integrity sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w== + ms@^2.1.3: version "2.1.3" resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" @@ -3806,10 +4029,10 @@ node-gyp@^10.0.0: tar "^6.1.2" which "^4.0.0" -nodemailer@^6.10.0: - version "6.10.0" - resolved "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.0.tgz" - integrity sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA== +nodemailer@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-9.0.1.tgz#871c04d8423fc0c790289f4a8f6f71f1b3563e8c" + integrity sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw== nopt@^7.0.0: version "7.2.0" @@ -4199,6 +4422,11 @@ polite-json@^5.0.0: resolved "https://registry.npmjs.org/polite-json/-/polite-json-5.0.0.tgz" integrity sha512-OLS/0XeUAcE8a2fdwemNja+udKgXNnY6yKVIXqAD2zVRx1KvY6Ato/rZ2vdzbxqYwPW0u6SCNC/bAMPNzpzxbw== +postal-mime@2.7.4: + version "2.7.4" + resolved "https://registry.yarnpkg.com/postal-mime/-/postal-mime-2.7.4.tgz#3718d1f188357ed86f906f1db8d4ca455efa4927" + integrity sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g== + postgres-array@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz" @@ -4359,6 +4587,22 @@ require-from-string@^2.0.2: resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== +require-in-the-middle@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz#dbde2587f669398626d56b20c868ab87bf01cce4" + integrity sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ== + dependencies: + debug "^4.3.5" + module-details-from-path "^1.0.3" + +resend@^6.14.0: + version "6.14.0" + resolved "https://registry.yarnpkg.com/resend/-/resend-6.14.0.tgz#c052238b31a981a14441646056c6e3dd541ba015" + integrity sha512-jVdpUgOoWGLjaP64lo8KwzHT9gY4w6Dl8c36CIb2F+ayYOMLr3khqs8xrNjXM2k19b+lPoj0VWQFhVNLiToBjA== + dependencies: + postal-mime "2.7.4" + standardwebhooks "1.0.0" + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz" @@ -4478,6 +4722,11 @@ secure-json-parse@^3.0.0, secure-json-parse@^3.0.1: resolved "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-3.0.2.tgz" integrity sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w== +semifies@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/semifies/-/semifies-1.0.0.tgz#b69569f32c2ba2ac04f705ea82831364289b2ae2" + integrity sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw== + semver@^7.0.0, semver@^7.1.1, semver@^7.3.5, semver@^7.5.3, semver@^7.6.0: version "7.7.1" resolved "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz" @@ -4658,6 +4907,14 @@ stack-utils@^2.0.6: dependencies: escape-string-regexp "^2.0.0" +standardwebhooks@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/standardwebhooks/-/standardwebhooks-1.0.0.tgz#5faa23ceacbf9accd344361101d9e3033b64324f" + integrity sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg== + dependencies: + "@stablelib/base64" "^1.0.0" + fast-sha256 "^1.3.0" + statuses@2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz"