diff --git a/README.md b/README.md index 34434eb..bc39917 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,36 @@ -# Astro Starter Kit: Basics +# Fi3D Slicer as a Service -``` -npm create astro@latest -- --template basics -``` +## API key -[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics) -[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics) -[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json) +add `Authorization: Bearer {token}` -> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! +## API Endpoints -![basics](https://user-images.githubusercontent.com/4677417/186188965-73453154-fdec-4d6b-9c34-cb35c248ae5b.png) +| endpoint | method | permission required | cookie access | api access | Description | +| :----------------------------: | :----: | :-----------------: | :-----------: | :--------: | :---------------------------------------: | +| /api/v1/users | GET | user.list | no | no | List every user accounts | +| /api/v1/users | POST | user.create | no | no | Create a new account | +| /api/v1/users/{userId} | GET | user.get | yes | yes | Get a user's informations | +| /api/v1/users/{userId} | PUT | user.set | yes | yes | Set a user's informations | +| /api/v1/users/{userId}/configs | GET | configs.get | yes | yes | get the list of the user's configurations | +| /api/v1/users/{userId}/configs | POST | configs.create | yes | yes | Add a new configuration to the user | +| /api/v1/users/{userId}/keys | GET | keys.get | yes | no | get the list of API key for the user | +| /api/v1/users/{userId}/keys | POST | keys.create | yes | no | create a new API key for the user | +| /api/v1/users/{userId}/process | POST | slicing.slice | yes | yes | run the main website action | +endpoints not available through API can still be accessed by admins with the `admin.` prefix to the permission -## 🚀 Project Structure +## API Key Permissions -Inside of your Astro project, you'll see the following folders and files: +### `slicing:*` permissions related to the slicing -``` -/ -├── public/ -│ └── favicon.svg -├── src/ -│ ├── components/ -│ │ └── Card.astro -│ ├── layouts/ -│ │ └── Layout.astro -│ └── pages/ -│ └── index.astro -└── package.json -``` +| name | Description | +| :-----------: | :---------------------: | +| slicing:slice | Slice the specified STL | -Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. +### `configs:*` permissions related to the configuration files -There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. - -Any static assets, like images, can be placed in the `public/` directory. - -## 🧞 Commands - -All commands are run from the root of the project, from a terminal: - -| Command | Action | -| :------------------------ | :----------------------------------------------- | -| `npm install` | Installs dependencies | -| `npm run dev` | Starts local dev server at `localhost:3000` | -| `npm run build` | Build your production site to `./dist/` | -| `npm run preview` | Preview your build locally, before deploying | -| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | -| `npm run astro -- --help` | Get help using the Astro CLI | - -## 👀 Want to learn more? - -Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). +| name | description | +| :------------: | :--------------------------------: | +| configs:create | Create a new configuration file | +| configs:get | Get an existing configuration file | diff --git a/astro.config.mjs b/astro.config.mjs index 0e87692..f118839 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,6 +1,6 @@ -import { defineConfig } from 'astro/config'; -import tailwind from "@astrojs/tailwind"; -import node from "@astrojs/node"; +import { defineConfig } from 'astro/config' +import tailwind from "@astrojs/tailwind" +import node from "@astrojs/node" // https://astro.build/config export default defineConfig({ @@ -17,5 +17,13 @@ export default defineConfig({ output: 'server', adapter: node({ mode: "standalone" - }) + }), + vite: { + server: { + watch: { + // support WSL strange things + usePolling: !!process.env.WSL_DISTRO_NAME + } + } + } }) diff --git a/src/components/Passthrough.astro b/src/components/Passthrough.astro new file mode 100644 index 0000000..a31a5df --- /dev/null +++ b/src/components/Passthrough.astro @@ -0,0 +1,16 @@ +--- +const json = JSON.stringify(Astro.props) + +/** + * note: you MUST only pass simple items that can go in JSON format natively + */ +export function load(): T { + const tag = document.querySelector('#ASTRO_DATA') + if (!tag) { + throw new Error('could not load client variables, tag not found') + } + return JSON.parse(tag.innerText) +} + +--- + diff --git a/src/libs/CookieManager.ts b/src/libs/CookieManager.ts new file mode 100644 index 0000000..949e326 --- /dev/null +++ b/src/libs/CookieManager.ts @@ -0,0 +1,56 @@ +import type { ServerResponse } from 'node:http' + +export default class CookieManager { + private cookies: Record = {} + public constructor(data?: string | Record) { + if (typeof data === 'string') { + data.split(';').forEach((keyValuePair) => { + const [key, value] = keyValuePair.split('=') + this.cookies[key.trim()] = value.trim().replace(/%3B/g, ';') + }) + } else if (typeof data === 'object') { + this.cookies = data + } + } + + public static addCookie(res: ServerResponse, cookie: { + key: string + value: string + expire?: string + maxAge?: number + domain?: string + path?: string + secure?: boolean + httpOnly?: boolean + sameSite?: 'Lax' | 'None' | 'Strict'} + ) { + const items: Array = [`${cookie.key}=${cookie.value.replace(/;/g, '%3B')}`] + if (cookie.expire) { + items.push(`Expires=${cookie.expire}`) + } + if (cookie.maxAge) { + items.push(`Max-Age=${cookie.maxAge}`) + } + if (cookie.domain) { + items.push(`Domain=${cookie.domain}`) + } + if (cookie.path) { + items.push(`Path=${cookie.path}`) + } + if (cookie.secure) { + items.push('Secure') + } + if (cookie.httpOnly) { + items.push('HttpOnly') + } + if (cookie.sameSite) { + items.push(`SameSite=${cookie.sameSite}`) + } + res.setHeader('Set-Cookie', items.join('; ')) + } + + public get(key: string): string | undefined { + return this.cookies[key] + } + +} diff --git a/src/libs/RFCs/RFC7807.ts b/src/libs/RFCs/RFC7807.ts index da6b6fc..0fd0887 100644 --- a/src/libs/RFCs/RFC7807.ts +++ b/src/libs/RFCs/RFC7807.ts @@ -52,6 +52,11 @@ export default interface RFC7807 { instance?: string } +/** + * + * @param error the error (base items are type, status, title details and instance) + * @returns + */ export function buildRFC7807(error: RFC7807 & Record): Response { return new Response(JSON.stringify(error), { headers: { diff --git a/src/libs/validateAuth.ts b/src/libs/validateAuth.ts new file mode 100644 index 0000000..6447524 --- /dev/null +++ b/src/libs/validateAuth.ts @@ -0,0 +1,123 @@ +import DaoFactory from '../models/DaoFactory' +import CookieManager from './CookieManager' +import RFC7807, { buildRFC7807 } from './RFCs/RFC7807' + +interface Permission { + name: string + /** + * if set it will be usable by users + * + * else only users with the `admin.` prefix in the key can run it + */ + api: boolean + + /** + * if set to true it will pass if a cookie authenticate a valid user + */ + cookie: boolean +} + +/** + * validate the authentification + * @param request the request + * @param permission the permission to validate + * @returns a Response if the request is invalid, null otherwise + * + * TODO: implement rate limiting + * http/2.0 429 TOO MANY REQUESTS + * Content-Type: application/json + * X-RateLimit-Limit: 1000 + * X-RateLimit-Remaining: 0 + * X-RateLimit-Reset: 123456789 + * X-RateLimit-Reset-After: 9 // Number of seconds before the remaining Rate go back to 0 + */ +export async function validateAuth(request: Request, permission: Permission): Promise { + const apiKeyHeader = request.headers.get('Authorization') + const cookieHeader = request.headers.get('Cookie') + if (apiKeyHeader) { + const apiKey = apiKeyHeader.slice(apiKeyHeader.lastIndexOf(' ') + 1) + const dao = await DaoFactory.get('apiKey').findOne({ + key: apiKey + }) + const perm = permission.name.split('.') + const match = dao?.permissions.find((it) => { + const itSplit = it.split('.') + if (itSplit[0] === 'admin') { + itSplit.shift() + } + for (let idx = 0; idx < itSplit.length; idx++) { + const permissionLayer = itSplit[idx] + const requestPermissionLayer = perm[idx] + if (permissionLayer === '*') { + return true + } else if (permissionLayer !== requestPermissionLayer) { + return false + } + } + return itSplit.length === perm.length + // return it.endsWith(permission.name) + }) + if (match && (permission.api || match.startsWith('admin.'))) { + return null + } else if (permission.api) { + return buildRFC7807({ + type: '/unauthorized-access', + status: 401, + title: 'Unauthorized access', + details: `you are missing the permission "${permission.name}" or is not an admin` + }) + } + } else if (permission.api && !permission.cookie) { + return buildRFC7807({ + type: '/unauthorized-access', + status: 401, + title: 'Unauthorized access', + details: `You MUST define an API key fo use this endpoint` + }) + } + + if (cookieHeader && permission.cookie) { + // TODO: make a better cookie implementation + const cookies = new CookieManager(cookieHeader) + const userCookie = cookies.get('userId') + if (!userCookie) { + return buildRFC7807({ + type: '/unauthorized-access', + status: 401, + title: 'Unauthorized access', + details: `you must be connected to use this endpoint (missing the userId cookie)` + }) + } + const dao = await DaoFactory.get('user').get(userCookie) + if (!dao) { + return buildRFC7807({ + type: '/unauthorized-access', + status: 401, + title: 'Unauthorized access', + details: `the user does not exists` + }) + } + return null + } else if (!permission.api && permission.cookie) { + return buildRFC7807({ + type: '/unauthorized-access', + status: 401, + title: 'Unauthorized access', + details: `You MUST be connected to your account to use this endpoint` + }) + } else if (permission.api && permission.cookie) { + return buildRFC7807({ + type: '/unauthorized-access', + status: 401, + title: 'Unauthorized access', + details: `You must be connected or use an API key to access this endpoint` + }) + } + return buildRFC7807({ + type: '/unauthorized-access', + status: 401, + title: 'Unauthorized access', + details: `the following endpoint is not currently accessible by any means` + }) + +} diff --git a/src/models/APIKey/APIKeyDao.ts b/src/models/APIKey/APIKeyDao.ts new file mode 100644 index 0000000..7841add --- /dev/null +++ b/src/models/APIKey/APIKeyDao.ts @@ -0,0 +1,79 @@ +import { objectOmit } from '@dzeio/object-util' +import mongoose from 'mongoose' +import type APIKey from '.' +import Client from '../Client' +import Dao from '../Dao' + +export default class APIKeyDao extends Dao { + + // @ts-expect-error typing fix + private model = mongoose.models['APIKey'] as null ?? mongoose.model('APIKey', new mongoose.Schema({ + user: { type: String, required: true }, + key: { type: String, required: true, unique: true, index: true}, + permissions: [{ type: String }] + }, { + timestamps: true + })) + + public async create(obj: Omit): Promise { + await Client.get() + return this.fromSource(await this.model.create(obj)) + } + + public async findAll(query?: Partial | undefined): Promise { + await Client.get() + try { + if (query?.id) { + const item = await this.model.findById(new mongoose.Types.ObjectId(query.id)) + if (!item) { + return [] + } + return [this.fromSource(item)] + } + const resp = await this.model.find(query ? this.toSource(query as APIKey) : {}) + return resp.map(this.fromSource) + } catch (e) { + console.error(e) + return [] + } + + } + + public async update(obj: APIKey): Promise { + await Client.get() + + const query = await this.model.updateOne({ + _id: new mongoose.Types.ObjectId(obj.id) + }, this.toSource(obj)) + if (query.matchedCount >= 1) { + obj.updated = new Date() + return obj + } + return null + // return this.fromSource() + } + + public async delete(obj: APIKey): Promise { + await Client.get() + const res = await this.model.deleteOne({ + _id: new mongoose.Types.ObjectId(obj.id) + }) + return res.deletedCount > 0 + } + + private toSource(obj: APIKey): Omit { + return objectOmit(obj, 'id', 'updated', 'created') + } + + private fromSource(doc: mongoose.Document): APIKey { + return { + id: doc._id.toString(), + user: doc.get('user'), + key: doc.get('key'), + permissions: doc.get('permissions') ?? [], + updated: doc.get('updatedAt'), + created: doc.get('createdAt') + } + } + +} diff --git a/src/models/APIKey/ApiKeyDao.ts b/src/models/APIKey/ApiKeyDao.ts deleted file mode 100644 index a8f2ae6..0000000 --- a/src/models/APIKey/ApiKeyDao.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type APIKey from '.' -import Dao from '../Dao' - -export default class APIKeyDao extends Dao { - private idx = 0 - public async create(obj: Omit): Promise { - console.log('pouet', this.idx++) - return null - // throw new Error('Method not implemented.') - } - public async findAll(query?: Partial | undefined): Promise { - throw new Error('Method not implemented.') - } - public async update(obj: APIKey): Promise { - throw new Error('Method not implemented.') - } - public async delete(obj: APIKey): Promise { - throw new Error('Method not implemented.') - } - -} diff --git a/src/models/APIKey/index.ts b/src/models/APIKey/index.ts index 0f99e08..d6428e6 100644 --- a/src/models/APIKey/index.ts +++ b/src/models/APIKey/index.ts @@ -1,4 +1,8 @@ export default interface APIKey { id: string user: string + key: string + permissions: Array + created: Date + updated: Date } diff --git a/src/models/DaoFactory.ts b/src/models/DaoFactory.ts index 8854bdb..3646e70 100644 --- a/src/models/DaoFactory.ts +++ b/src/models/DaoFactory.ts @@ -1,4 +1,4 @@ -import Client from './Client' +import APIKeyDao from './APIKey/APIKeyDao' import ConfigDao from './Config/ConfigDao' import Dao from './Dao' import UserDao from './User/UserDao' @@ -17,6 +17,7 @@ import UserDao from './User/UserDao' interface DaoItem { config: ConfigDao user: UserDao + apiKey: APIKeyDao } /** @@ -57,6 +58,7 @@ export default class DaoFactory { switch (item) { case 'config': return new ConfigDao() case 'user': return new UserDao() + case 'apiKey': return new APIKeyDao() default: return undefined } } diff --git a/src/models/User/UserDao.ts b/src/models/User/UserDao.ts index 8b944e7..e8cae67 100644 --- a/src/models/User/UserDao.ts +++ b/src/models/User/UserDao.ts @@ -6,19 +6,18 @@ import Dao from '../Dao' export default class UserDao extends Dao { - private model = mongoose.model('User', new mongoose.Schema({ + // @ts-expect-error typing fix + private model = mongoose.models['User'] as null ?? mongoose.model('User', new mongoose.Schema({ email: { type: String, required: true } }, { timestamps: true })) - private collection = 'users' - - private idx = 0 public async create(obj: Omit): Promise { await Client.get() return this.fromSource(await this.model.create(obj)) } + public async findAll(query?: Partial | undefined): Promise { await Client.get() if (query?.id) { @@ -32,6 +31,7 @@ export default class UserDao extends Dao { return resp.map(this.fromSource) } + public async update(obj: User): Promise { await Client.get() @@ -45,6 +45,7 @@ export default class UserDao extends Dao { return null // return this.fromSource() } + public async delete(obj: User): Promise { await Client.get() const res = await this.model.deleteOne({ diff --git a/src/pages/admin.astro b/src/pages/admin.astro new file mode 100644 index 0000000..16afba9 --- /dev/null +++ b/src/pages/admin.astro @@ -0,0 +1,41 @@ +--- +import Passthrough from '../components/Passthrough.astro' +import Layout from '../layouts/Layout.astro' +import DaoFactory from '../models/DaoFactory' + +const user = await DaoFactory.get('user').get('648f81f857503c7d29465318') +const list = await DaoFactory.get('apiKey').findAll({ + user: user!.id +}) +const userId = user?.id ?? 'unknown' + +--- + + +
+
    + {list.map((it) => ( +
  • +

    access key: {it.key}

    +

    permissions: {it.permissions}

    +
  • + ))} + + +
+
+ +
+ + diff --git a/src/pages/api/users/[userId]/keys/index.ts b/src/pages/api/users/[userId]/keys/index.ts new file mode 100644 index 0000000..4015342 --- /dev/null +++ b/src/pages/api/users/[userId]/keys/index.ts @@ -0,0 +1,19 @@ +import type { APIRoute } from 'astro' +import crypto from 'node:crypto' +import DaoFactory from '../../../../../models/DaoFactory' + +export const post: APIRoute = async ({ params, request }) => { + const userId = params.userId as string + + const dao = await DaoFactory.get('apiKey').create({ + user: userId, + key: crypto.randomUUID(), + permissions: [ + 'admin.user.list' + ] + }) + return { + status: 201, + body: JSON.stringify(dao) + } +} diff --git a/src/pages/api/users/index.ts b/src/pages/api/users/index.ts new file mode 100644 index 0000000..6c20cad --- /dev/null +++ b/src/pages/api/users/index.ts @@ -0,0 +1,17 @@ +import type { APIRoute } from 'astro' +import { validateAuth } from '../../../libs/validateAuth' + +export const get: APIRoute = async ({ params, request }) => { + const requestInvalid = await validateAuth(request, { + name: 'user.list', + api: false, + cookie: true + }) + if (requestInvalid) { + return requestInvalid + } + return { + status: 200, + body: JSON.stringify({iam: true}) + } +}