Skip to content
Open
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
34 changes: 34 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/

**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@

e2e/npm-debug.log

**/.tmp/**
**/.temp/**

63 changes: 63 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# syntax=docker/dockerfile:1

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/

# Want to help us make this template better? Share your feedback here: https://forms.gle/ybq9Krt8jtBL3iCk7

################################################################################
# Pick a base image to serve as the foundation for the other build stages in
# this file.
#
# For illustrative purposes, the following FROM command
# is using the alpine image (see https://hub.docker.com/_/alpine).
# By specifying the "latest" tag, it will also use whatever happens to be the
# most recent version of that image when you build your Dockerfile.
# If reproducibility is important, consider using a versioned tag
# (e.g., alpine:3.17.2) or SHA (e.g., alpine@sha256:c41ab5c992deb4fe7e5da09f67a8804a46bd0592bfdf0b1847dde0e0889d2bff).
FROM alpine:latest as base

################################################################################
# Create a stage for building/compiling the application.
#
# The following commands will leverage the "base" stage above to generate
# a "hello world" script and make it executable, but for a real application, you
# would issue a RUN command for your application's build process to generate the
# executable. For language-specific examples, take a look at the Dockerfiles in
# the Awesome Compose repository: https://github.com/docker/awesome-compose
FROM base as build
RUN echo -e '#!/bin/sh\n\
echo Hello world from $(whoami)! In order to get your application running in a container, take a look at the comments in the Dockerfile to get started.'\
> /bin/hello.sh
RUN chmod +x /bin/hello.sh

################################################################################
# Create a final stage for running your application.
#
# The following commands copy the output from the "build" stage above and tell
# the container runtime to execute it when the image is run. Ideally this stage
# contains the minimal runtime dependencies for the application as to produce
# the smallest image possible. This often means using a different and smaller
# image than the one used for building the application, but for illustrative
# purposes the "base" image is used here.
FROM base AS final

# Create a non-privileged user that the app will run under.
# See https://docs.docker.com/go/dockerfile-user-best-practices/
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
appuser
USER appuser

# Copy the executable from the "build" stage.
COPY --from=build /bin/hello.sh /bin/

# What the container should run when it is started.
ENTRYPOINT [ "/bin/hello.sh" ]
17 changes: 17 additions & 0 deletions README.Docker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
### Building and running your application

When you're ready, start your application by running:
`docker compose up --build`.

### Deploying your application to the cloud

First, build your image, e.g.: `docker build -t myapp .`.
If your cloud uses a different CPU architecture than your development
machine (e.g., you are on a Mac M1 and your cloud provider is amd64),
you'll want to build the image for that platform, e.g.:
`docker build --platform=linux/amd64 -t myapp .`.

Then, push it to your registry, e.g. `docker push myregistry.com/myapp`.

Consult Docker's [getting started](https://docs.docker.com/go/get-started-sharing/)
docs for more detail on building and pushing.
52 changes: 52 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/

# Here the instructions define your application as a service called "app".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
app:
build:
context: .
target: final
# If your application exposes a port, uncomment the following lines and change
# the port numbers as needed. The first number is the host port and the second
# is the port inside the container.
# ports:
# - 8080:8080

# The commented out section below is an example of how to define a PostgreSQL
# database that your application can use. `depends_on` tells Docker Compose to
# start the database before your application. The `db-data` volume persists the
# database data between container restarts. The `db-password` secret is used
# to set the database password. You must create `db/password.txt` and add
# a password of your choosing to it before running `docker compose up`.
# depends_on:
# db:
# condition: service_healthy
# db:
# image: postgres
# restart: always
# user: postgres
# secrets:
# - db-password
# volumes:
# - db-data:/var/lib/postgresql/data
# environment:
# - POSTGRES_DB=example
# - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
# expose:
# - 5432
# healthcheck:
# test: [ "CMD", "pg_isready" ]
# interval: 10s
# timeout: 5s
# retries: 5
# volumes:
# db-data:
# secrets:
# db-password:
# file: db/password.txt
1 change: 1 addition & 0 deletions src/API/Dockerfile

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclude this file from PR merge request.

Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,4 @@ COPY --from=builder /usr/src/app/colormap.txt .
ENTRYPOINT ["/entrypoint.sh"]
# Start the server using the production build
CMD [ "node", "dist/src/main.js" ]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclude this file from PR merge request.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclude this file from PR merge request.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclude this file from PR merge request.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclude this file from PR merge request.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclude this file from PR merge request.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclude this file from PR merge request.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Comment thread
IndianaEunice marked this conversation as resolved.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclude this file from PR merge request.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclude this file from PR merge request.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclude this file from PR merge request.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclude this file from PR merge request.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclude this file from PR merge request.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclude this file from PR merge request.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclude this file from PR merge request.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,18 @@ export class SpeciesInformation extends BaseEntity {
@Field({ nullable: false })
description: string;

@Column('varchar', { nullable: false })
@Field({ nullable: false })
// Original, large JPEG. Only ever needed for downloading —
// never fetched by the list query.
@Column('varchar', { nullable: true })
@Field({ nullable: true })
speciesImage: string;

// Small WebP version, generated automatically whenever a new
// speciesImage is uploaded. This is what gets displayed everywhere.
@Column('varchar', { nullable: true })
@Field({ nullable: true })
previewImage: string;

@Column('varchar', { nullable: false })
@Field({ nullable: false })
distributionMapUrl: string;
Expand Down
132 changes: 132 additions & 0 deletions src/API/src/db/speciesInformation/speciesInformation.controller.ts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need clarification, are images stored in the database or in the azure blob?

Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import {
Controller,
Post,
Get,
Param,
Res,
UploadedFile,
UseInterceptors,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { join } from 'path';
import * as fs from 'fs';
import { AzureBlobService } from '../azure-blob/azure-blob.service';
import { SpeciesInformationService } from './speciesInformation.service';

// CHANGED: TypeScript kept resolving sharp's type declarations as
// non-callable in this project's config, regardless of import style
// (`import sharp from`, `import * as sharp from`, and
// `import sharp = require(...)` all failed the same way). Plain
// require() sidesteps that entirely — sharp becomes typed as `any`
// and isn't type-checked at all, only used at runtime.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sharp = require('sharp');

@Controller('species-information')
export class SpeciesInformationController {
constructor(
private readonly azureBlobService: AzureBlobService,
private readonly speciesInformationService: SpeciesInformationService,
) {}

// Uploads a new species image. Two things happen here:
// 1. The original JPEG is uploaded as-is (this becomes speciesImage).
// 2. A smaller WebP copy is generated on the server and uploaded too
// (this becomes previewImage). The frontend never has to do any
// image conversion itself.
@Post('upload-image')
@UseInterceptors(FileInterceptor('file'))
async uploadImage(@UploadedFile() file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('No file provided');
}

// Only JPEG is accepted, since that's the documented format and
// the only one we're set up to convert to WebP here.
if (file.mimetype !== 'image/jpeg') {
throw new BadRequestException('Only JPEG images are supported');
}

// Step 1: upload the original JPEG, unchanged
const originalResult = await this.azureBlobService.upload(
file,
'species-images',
);

// Step 2: build a smaller WebP version in memory.
// - resize() caps the width so the preview is genuinely lighter
// - withoutEnlargement stops small images being blown up
// - webp({ quality: 80 }) is a solid size/quality balance
const previewBuffer = await sharp(file.buffer)
.resize({ width: 800, withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer();

// Step 3: upload that WebP buffer as its own file, in its own
// container, so it's a completely separate object from the original
const previewFile: Express.Multer.File = {
...file,
buffer: previewBuffer,
size: previewBuffer.length,
mimetype: 'image/webp',
originalname: file.originalname.replace(/\.jpe?g$/i, '.webp'),
};
const previewResult = await this.azureBlobService.upload(
previewFile,
'species-images-preview',
);

// Store only the bare filename (no directory prefix, no host/URL) so
// it matches the format the rest of the species_information table
// uses, and what the /images/:filename route expects when it looks
// the file up under public/species-images/.
const stripDirectory = (filePath: string) => filePath.split('/').pop();

return {
imageUrl: stripDirectory(originalResult.filePath),
fileName: stripDirectory(originalResult.filePath),
previewImageUrl: stripDirectory(previewResult.filePath),
previewFileName: stripDirectory(previewResult.filePath),
};
}

// On-demand download route. The list page never loads speciesImage,
// so when someone clicks "Download" there, the frontend calls this
// route with just the species id. We look up speciesImage here and
// redirect the browser to the real file — either straight to Azure
// (if it's already a full URL) or through our own local image route
// (if it's just a bare filename sitting in the flat species-images
// folder, as with the manually-populated legacy rows).
@Get(':id/download-image')
async downloadImage(@Param('id') id: string, @Res() res: Response) {
const species =
await this.speciesInformationService.getSpeciesImageForDownload(id);

if (!species || !species.speciesImage) {
throw new NotFoundException('Image not found');
}

const isFullUrl =
species.speciesImage.startsWith('http://') ||
species.speciesImage.startsWith('https://');

const redirectTarget = isFullUrl
? species.speciesImage
: `/vector-api/species-information/images/${species.speciesImage}`;

return res.redirect(redirectTarget);
}

@Get('images/:filename')
async getImage(@Param('filename') filename: string, @Res() res: Response) {
const filePath = join(process.cwd(), 'public', 'species-images', filename);

if (!fs.existsSync(filePath)) {
throw new NotFoundException('Image not found');
}
return res.sendFile(filePath);
}
}
12 changes: 11 additions & 1 deletion src/API/src/db/speciesInformation/speciesInformation.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,20 @@ import { Module } from '@nestjs/common';
import { SpeciesInformation } from './entities/speciesInformation.entity';
import { SpeciesInformationService } from './speciesInformation.service';
import { SpeciesInformationResolver } from './speciesInformation.resolver';
import { SpeciesInformationController } from './speciesInformation.controller';
import { AzureBlobService } from 'src/db/azure-blob/azure-blob.service';

@Module({
imports: [TypeOrmModule.forFeature([SpeciesInformation])],
providers: [SpeciesInformationService, SpeciesInformationResolver],
// This was previously an empty array — meaning your REST endpoints
// (upload-image, download-image, images/:filename) were never
// actually reachable. Registering the controller here fixes that.
controllers: [SpeciesInformationController],
providers: [
SpeciesInformationService,
SpeciesInformationResolver,
AzureBlobService,
],
exports: [SpeciesInformationService, SpeciesInformationResolver],
})
export class SpeciesInformationModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export class CreateSpeciesInformationInput {
@Field()
speciesImage: string;

// Lets the frontend send the WebP preview URL when creating/editing
@Field({ nullable: true })
previewImage: string;

@Field(() => [String])
citations?: string[];

Expand Down Expand Up @@ -83,7 +87,7 @@ export class SpeciesInformationResolver {

@UseGuards(GqlAuthGuard, RolesGuard)
@Roles(Role.Editor)
@Mutation(() => Boolean) // Return Boolean to indicate success or failure
@Mutation(() => Boolean)
async deleteSpeciesInformation(
@Args('id', { type: () => String }) id: string,
): Promise<boolean> {
Expand Down
Loading
Loading