Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Build outputs
.next
out
dist
build

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Keep .env.production for Docker builds
!.env.production

# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# Testing
coverage
.nyc_output
.jest

# IDE and editor files
.vscode
.idea
*.swp
*.swo
*~

# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

# Git
.git
.gitignore

# TypeScript
*.tsbuildinfo

# Miscellaneous
README.md
LICENSE
*.md
.github

# Cache directories
.cache
.parcel-cache
.swc

# Husky
.husky
5 changes: 5 additions & 0 deletions .env.production
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
NEXT_PUBLIC_BSKY_BASE_API_URL=https://api.bsky.app
NEXT_PUBLIC_BSKY_BASE=https://bsky.social
NEXT_PUBLIC_MAX_STACKED_MODALS=3
# Use the URL specific to Docker container networking
NEXT_PUBLIC_SAFE_SKIES_API=http://backend:4000
2 changes: 1 addition & 1 deletion .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ NEXT_PUBLIC_MAX_STACKED_MODALS=3

NEXT_PUBLIC_CLIENT_URL=http://localhost:3000 (for local development)

NEXT_PUBLIC_SAFE_SKIES_API= < url where you host the api if running your own instance >
NEXT_PUBLIC_SAFE_SKIES_API=http://localhost:4000

48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: CI - Validate Frontend Changes

on:
pull_request:
branches: [main]
push:
branches: [main]

jobs:
validate:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run type checking
run: npm run type-check

- name: Run linting
run: npm run lint

- name: Run tests
run: npm run test

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build Docker image for testing
uses: docker/build-push-action@v5
with:
context: .
target: prodrunner
load: true
tags: safe-skies-frontend:test
build-args: |
NEXT_PUBLIC_SAFE_SKIES_API=http://safe-skies-api:4000
cache-from: type=gha
cache-to: type=gha,mode=max
49 changes: 49 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Deploy Frontend to Production

on:
push:
branches: [main]

jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DO_REGISTRY_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DO_REGISTRY_URL }}
tags: |
type=sha,prefix={{branch}}-
type=raw,value=latest

- name: Log in to DigitalOcean Container Registry with short-lived credentials
run: doctl registry login --expiry-seconds 1200

- name: Build and push image to DigitalOcean Container Registry
uses: docker/build-push-action@v5
with:
context: .
target: prodrunner
push: true
tags: ${{ steps.meta.outputs.tags }}
build-args: |
NEXT_PUBLIC_SAFE_SKIES_API=http://safe-skies-api:4000
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
39 changes: 39 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Node LTS
ARG NODE_VERSION=22.14.0

FROM node:${NODE_VERSION}-alpine AS base
WORKDIR /usr/src/app
EXPOSE 3000

FROM base AS dev
RUN --mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
--mount=type=cache,target=/home/node/.npm \
npm ci --include=dev

USER node
COPY . .
CMD npm run dev

FROM base AS prodbuilder

ARG NEXT_PUBLIC_SAFE_SKIES_API

RUN --mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
--mount=type=cache,target=/home/node/.npm \
npm ci

RUN chown -R node:node /usr/src/app

USER node
COPY --chown=node:node . .
RUN npm run build

FROM base AS prodrunner
USER node
COPY --from=prodbuilder --chown=node:node /usr/src/app/.next/standalone ./
COPY --from=prodbuilder --chown=node:node /usr/src/app/.next/static ./.next/static
COPY --from=prodbuilder --chown=node:node /usr/src/app/public ./public

CMD ["node", "server.js"]
71 changes: 71 additions & 0 deletions app/api/moderation/emit-event/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { fetchWithAuth } from '@/lib/api';
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
try {
const body = await request.json();

// Validate required fields
if (!body.did) {
return NextResponse.json(
{ error: 'Missing required field: did' },
{ status: 400 }
);
}

if (!body.eventType) {
return NextResponse.json(
{ error: 'Missing required field: eventType' },
{ status: 400 }
);
}

// Forward to backend
const response = await fetchWithAuth('/api/moderation/emit-event', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});

if (!response) {
return NextResponse.json(
{ error: 'Failed to emit moderation event' },
{ status: 500 }
);
}

// Parse response safely
const rawText = await response.text();

let data;
try {
data = JSON.parse(rawText);
} catch (jsonError) {
return NextResponse.json(
{
error: jsonError instanceof Error
? jsonError.message
: 'Invalid JSON returned from backend',
raw: rawText,
},
{ status: response.status }
);
}

if (!response.ok) {
return NextResponse.json(
{ error: data.error || 'Failed to emit moderation event' },
{ status: response.status }
);
}

return NextResponse.json(data);
} catch (error: unknown) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
);
}
}
57 changes: 57 additions & 0 deletions app/api/moderation/escalated-users/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { fetchWithAuth } from '@/lib/api';
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const limit = searchParams.get('limit') || '20';
const cursor = searchParams.get('cursor');

const url = cursor
? `/api/moderation/escalated-users?limit=${limit}&cursor=${encodeURIComponent(cursor)}`
: `/api/moderation/escalated-users?limit=${limit}`;

const response = await fetchWithAuth(url);

if (!response) {
return NextResponse.json(
{ error: 'Failed to fetch escalated users' },
{ status: 500 }
);
}

const rawText = await response.text();

let data;
try {
data = JSON.parse(rawText);
} catch (jsonError) {
return NextResponse.json(
{
error:
jsonError instanceof Error
? jsonError.message
: 'Invalid JSON returned from backend',
raw: rawText,
},
{ status: response.status }
);
}

if (!response.ok) {
return NextResponse.json(
{ error: data.error || 'Failed to fetch escalated users' },
{ status: response.status }
);
}

return NextResponse.json(data);
} catch (error: unknown) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Failed to fetch escalated users',
},
{ status: 500 }
);
}
}
Loading