Refactor for using koa and dicebear
This commit is contained in:
parent
41f480e2a6
commit
1840e5a786
17 changed files with 976 additions and 1129 deletions
8
.env.example
Normal file
8
.env.example
Normal file
|
@ -0,0 +1,8 @@
|
|||
# General Config
|
||||
NODE_ENV="development"
|
||||
|
||||
# Server Config
|
||||
HOST="0.0.0.0"
|
||||
PORT=3001
|
||||
API_VERSION=1
|
||||
ALLOWED_ORIGINS="http://localhost:3000,https://jusemon.com"
|
5
LICENSE.md
Normal file
5
LICENSE.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
34
README.md
Normal file
34
README.md
Normal file
|
@ -0,0 +1,34 @@
|
|||
# Avatars API
|
||||
|
||||
A self-hosted avatars API using some of the [DiceBear](https://www.dicebear.com/) CC0 styles
|
||||
|
||||
# Installation
|
||||
|
||||
For run locally it needs a `.env` file with the environment variables defined in the `.env.example`
|
||||
|
||||
ALLOWED_ORIGINS includes a comma separated list for the allowed CORS sites.
|
||||
|
||||
Install dependencies `npm install` or `yarn`
|
||||
To start the project run `npm run dev` or `yarn dev`
|
||||
|
||||
## Libraries Used
|
||||
|
||||
- DiceBear (for avatar generation)
|
||||
- sharp (for png exportation)
|
||||
- uuid (for random seeds)
|
||||
|
||||
## Credits
|
||||
|
||||
- Created by Juan Sebastián Montoya
|
||||
|
||||
## Credits for Styles:
|
||||
|
||||
| Title | Creator | Source | Homepage | License |
|
||||
| ---------- | ------------- | -------------------------------------------------------- | -------------------------------- | ------- |
|
||||
| Open Peeps | Pablo Stanley | https://www.openpeeps.com/ | https://twitter.com/pablostanley | CC0 1.0 |
|
||||
| Pixel Art | DiceBear | https://www.figma.com/community/file/1198754108850888330 | https://www.dicebear.com | CC0 1.0 |
|
||||
| Thumbs | DiceBear | https://www.dicebear.com | https://www.dicebear.com | CC0 1.0 |
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](https://opensource.org/licenses/MIT).
|
14
index.ts
14
index.ts
|
@ -1,14 +0,0 @@
|
|||
import express, { Express } from 'express';
|
||||
import avatarsMiddleware from 'adorable-avatars';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app: Express = express();
|
||||
const port = process.env.PORT;
|
||||
|
||||
app.use('/', avatarsMiddleware);
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`⚡️[server]: Server is running at https://localhost:${port}`);
|
||||
});
|
25
package.json
25
package.json
|
@ -6,21 +6,34 @@
|
|||
"scripts": {
|
||||
"build": "npx tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "nodemon index.ts"
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"preinstall": "yarn config set ignore-engines true"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"adorable-avatars": "^0.5.0",
|
||||
"@dicebear/core": "^9.2.2",
|
||||
"@dicebear/open-peeps": "^9.2.2",
|
||||
"@dicebear/pixel-art": "^9.2.2",
|
||||
"@dicebear/thumbs": "^9.2.2",
|
||||
"@koa/cors": "^5.0.0",
|
||||
"dotenv": "^16.0.1",
|
||||
"express": "^4.18.1"
|
||||
"joi": "^17.13.3",
|
||||
"koa": "^2.15.3",
|
||||
"koa-logger": "^3.2.1",
|
||||
"koa-router": "^12.0.1",
|
||||
"sharp": "^0.33.5",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/koa": "^2.15.0",
|
||||
"@types/koa-logger": "^3.1.5",
|
||||
"@types/koa-router": "^7.4.8",
|
||||
"@types/koa__cors": "^5.0.0",
|
||||
"@types/node": "^18.7.3",
|
||||
"nodemon": "^2.0.19",
|
||||
"ts-node": "^10.9.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "^4.7.4"
|
||||
}
|
||||
}
|
||||
|
|
24
src/config/components/general.config.ts
Normal file
24
src/config/components/general.config.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import joi from "joi";
|
||||
import { Config } from "../../types/config";
|
||||
import { server } from "./server.config";
|
||||
|
||||
const envSchema = joi
|
||||
.object({
|
||||
NODE_ENV: joi
|
||||
.string()
|
||||
.allow("development", "production", "test")
|
||||
.required(),
|
||||
})
|
||||
.unknown()
|
||||
.required();
|
||||
|
||||
const { error, value: envVars } = envSchema.validate(process.env);
|
||||
if (error) {
|
||||
throw new Error(`Config validation error: ${error.message}`);
|
||||
}
|
||||
|
||||
export const general: Config = {
|
||||
env: envVars.NODE_ENV,
|
||||
isDevelopment: envVars.NODE_ENV === "development",
|
||||
server,
|
||||
};
|
24
src/config/components/server.config.ts
Normal file
24
src/config/components/server.config.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import joi from "joi";
|
||||
import { ServerConfig } from "../../types/config";
|
||||
|
||||
const envSchema = joi
|
||||
.object({
|
||||
HOST: joi.string().default("localhost"),
|
||||
PORT: joi.number().default(3000),
|
||||
API_VERSION: joi.number().default(1),
|
||||
ALLOWED_ORIGINS: joi.string().default("http://localhost:3000"),
|
||||
})
|
||||
.unknown()
|
||||
.required();
|
||||
|
||||
const { error, value: envVars } = envSchema.validate(process.env);
|
||||
if (error) {
|
||||
throw new Error(`Config validation error: ${error.message}`);
|
||||
}
|
||||
|
||||
export const server: ServerConfig = {
|
||||
host: envVars.HOST,
|
||||
port: envVars.PORT,
|
||||
apiVersion: envVars.API_VERSION,
|
||||
origins: envVars.ALLOWED_ORIGINS.split(","),
|
||||
};
|
3
src/config/index.ts
Normal file
3
src/config/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import "dotenv/config";
|
||||
import { general } from "./components/general.config";
|
||||
export default general;
|
20
src/index.ts
Normal file
20
src/index.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import Koa from "koa";
|
||||
import logger from "koa-logger";
|
||||
|
||||
import config from "./config";
|
||||
import corsMiddleware from "./middlewares/cors.middleware";
|
||||
import errorMiddleware from "./middlewares/error.middleware";
|
||||
import avatarRoute from "./routes/avatar.route";
|
||||
import { startServerLog } from "./utils/server";
|
||||
|
||||
const { server } = config;
|
||||
const app = new Koa();
|
||||
|
||||
// Middlewares
|
||||
app.use(corsMiddleware());
|
||||
app.use(logger());
|
||||
app.use(errorMiddleware());
|
||||
|
||||
app.use(avatarRoute.routes()).use(avatarRoute.allowedMethods());
|
||||
|
||||
app.listen(server.port, server.host, startServerLog(server.port));
|
20
src/middlewares/cors.middleware.ts
Normal file
20
src/middlewares/cors.middleware.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import cors from "@koa/cors";
|
||||
import { Context } from "koa";
|
||||
import Router from "koa-router";
|
||||
|
||||
import config from "../config";
|
||||
|
||||
const { server } = config;
|
||||
|
||||
const origin = (ctx: Context) => {
|
||||
const [defaultOrigin] = server.origins;
|
||||
if (ctx.headers.origin && server.origins.indexOf(ctx.headers.origin) > -1) {
|
||||
return ctx.header.origin || defaultOrigin;
|
||||
}
|
||||
return defaultOrigin;
|
||||
};
|
||||
|
||||
const corsMiddleware: () => Router.IMiddleware<any, {}> = () =>
|
||||
cors({ origin });
|
||||
|
||||
export default corsMiddleware;
|
24
src/middlewares/error.middleware.ts
Normal file
24
src/middlewares/error.middleware.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import Router from "koa-router";
|
||||
import { isValidationError } from "../utils/type-guards";
|
||||
|
||||
const errorMiddleware: () => Router.IMiddleware<any, {}> =
|
||||
() => async (ctx, next) => {
|
||||
try {
|
||||
await next();
|
||||
} catch (err) {
|
||||
console.log("error middleware", err);
|
||||
if (err && (err as any).status >= 500) console.log("Error handler:", err);
|
||||
if (isValidationError(err)) {
|
||||
ctx.status = 400;
|
||||
} else {
|
||||
ctx.status = (err as any).status || 500;
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
status: "failed",
|
||||
message: (err as any).message || "Internal server error",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default errorMiddleware;
|
63
src/routes/avatar.route.ts
Normal file
63
src/routes/avatar.route.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
import Router from "koa-router";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { createAvatar, Style } from "@dicebear/core";
|
||||
import * as openPeeps from "@dicebear/open-peeps";
|
||||
import * as pixelArt from "@dicebear/pixel-art";
|
||||
import * as thumbs from "@dicebear/thumbs";
|
||||
import sharp from "sharp";
|
||||
|
||||
import { isValue, isSize, isStyle } from "../utils/type-guards";
|
||||
|
||||
const DENSITY = 72;
|
||||
const avatars: Record<string, Style<any> & { defaultSize: number }> = {
|
||||
"open-peeps": { ...openPeeps, defaultSize: 704 },
|
||||
"pixel-art": { ...pixelArt, defaultSize: 16 },
|
||||
thumbs: { ...thumbs, defaultSize: 100 },
|
||||
};
|
||||
|
||||
const validStyles = Object.keys(avatars);
|
||||
const isValidStyle = isStyle(validStyles);
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.get("/list", async (ctx) => {
|
||||
const styles = validStyles.map((key) => ({ ...avatars[key].meta, key }));
|
||||
ctx.body = { styles };
|
||||
});
|
||||
|
||||
router.use(async (ctx, next) => {
|
||||
ctx.set({
|
||||
Expires: new Date(Date.now() + 604800000).toUTCString(),
|
||||
});
|
||||
ctx.type = "image/png";
|
||||
await next();
|
||||
});
|
||||
|
||||
router.get("/random", async (ctx) => {
|
||||
const { style = "thumbs", size = "100" } = ctx.query;
|
||||
if (!isValidStyle(style) || !isSize(size)) {
|
||||
throw Error("Invalid query");
|
||||
}
|
||||
const seed = uuidv4();
|
||||
const density = (DENSITY * parseInt(size)) / avatars[style].defaultSize;
|
||||
ctx.body = sharp(
|
||||
Buffer.from(createAvatar(avatars[style], { seed }).toString()),
|
||||
{ density }
|
||||
).png();
|
||||
});
|
||||
|
||||
router.get("/:seed", async (ctx) => {
|
||||
const { seed, style = "thumbs", size = "100" } = ctx.query;
|
||||
if (!isValue(seed) || !isValidStyle(style) || !isSize(size)) {
|
||||
throw Error("Invalid query");
|
||||
}
|
||||
const density = (DENSITY * parseInt(size)) / avatars[style].defaultSize;
|
||||
ctx.body = sharp(
|
||||
Buffer.from(createAvatar(avatars[style], { seed }).toString()),
|
||||
{
|
||||
density,
|
||||
}
|
||||
).png();
|
||||
});
|
||||
|
||||
export default router;
|
12
src/types/config.ts
Normal file
12
src/types/config.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export type ServerConfig = {
|
||||
host: string;
|
||||
port: number;
|
||||
origins: ReadonlyArray<string>;
|
||||
apiVersion: number;
|
||||
};
|
||||
|
||||
export type Config = {
|
||||
env: string;
|
||||
isDevelopment: boolean;
|
||||
server: ServerConfig;
|
||||
};
|
20
src/utils/server.ts
Normal file
20
src/utils/server.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { NetworkInterfaceInfo, networkInterfaces } from "os";
|
||||
|
||||
export const startServerLog = (port: number) => async () => {
|
||||
const net = Object.values(networkInterfaces())
|
||||
.flat()
|
||||
.filter((v) => v?.family === "IPv4")
|
||||
.sort((v) => (v!.internal ? -1 : 1)) as Array<NetworkInterfaceInfo>;
|
||||
|
||||
console.info("Server started successfully!");
|
||||
console.info("You can now use the service.");
|
||||
|
||||
net.forEach(({ internal, address }) =>
|
||||
console.info(
|
||||
`\t${(internal ? "Local:" : "On Your Network:").padEnd(
|
||||
20,
|
||||
" "
|
||||
)}http://${address}:${port}`
|
||||
)
|
||||
);
|
||||
};
|
18
src/utils/type-guards.ts
Normal file
18
src/utils/type-guards.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { ValidationError } from "joi";
|
||||
|
||||
export const isValidationError = (error: unknown): error is ValidationError =>
|
||||
error instanceof ValidationError;
|
||||
|
||||
export const isValue = <T>(value: T | Array<T> | undefined): value is T =>
|
||||
!!value && !Array.isArray(value);
|
||||
|
||||
export const isSize = (value: unknown): value is string =>
|
||||
!Array.isArray(value) &&
|
||||
!isNaN(parseInt(value as string)) &&
|
||||
parseInt(value as string) <= 512 &&
|
||||
parseInt(value as string) >= 16;
|
||||
|
||||
export const isStyle =
|
||||
(validStyles: string[]) =>
|
||||
<T>(value: T | Array<T> | undefined): value is T =>
|
||||
!!value && !Array.isArray(value) && validStyles.includes(value as string);
|
|
@ -11,7 +11,7 @@
|
|||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
"target": "es2017", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
|
|
Loading…
Reference in a new issue