diff --git a/markdown/dev/reference/backend/api/apikey/en.md b/markdown/dev/reference/backend/api/apikey/en.md
index d34d9f1325c..7d1774fad91 100644
--- a/markdown/dev/reference/backend/api/apikey/en.md
+++ b/markdown/dev/reference/backend/api/apikey/en.md
@@ -12,38 +12,39 @@ refer to [the section on authenticating to the
API](/reference/backend/api#authentication).
-
-
-
-
## Create a new API key
Create a new API key. The API key will belong to the user who is authenticated
when making the call. Supported for both JWT and KEY authentication.
+
+The response to this API call is the only time the secret will be
+revealed.
+
+
### Endpoints
-| Method | Path | Description |
-| ------ | ---- | ----------- |
-| `POST` | `/apikey/jwt` | Create a new API key. Endpoint for JWT authentication |
-| `POST` | `/apikey/key` | Create a new API key. Endpoint for API key authentication |
+| Method | Path | Description | Auth |
+| ------ | ---- | ----------- | ---- |
+| | `/apikey/jwt` | Create a new API key | _jwt_ |
+| | `/apikey/key` | Create a new API key | _key_ |
### Parameters
-| Variable | Type | Description |
-| -------- | -------- | ----------- |
-| `name` | `string` | Create a new API key. Endpoint for JWT authentication |
-| `level` | `number` | A privilege level from 0 to 8. |
-| `expiresIn` | `number` | The number of seconds until this key expires. |
+| Where | Variable | Type | Description |
+| ----- | ------------- | -------- | ----------- |
+| _body_ | `name` | `string` | Create a new API key. Endpoint for JWT authentication |
+| _body_ | `level` | `number` | A privilege level from 0 to 8. |
+| _body_ | `expiresIn` | `number` | body | The number of seconds until this key expires. |
-Returns status code `200` on success, `400` on if the request is malformed, and
-`500` on server error.
+Returns HTTP status code on success, if
+the request is malformed, and on server error.
| Value | Type | Description |
| ------------------- | -------- | ----------- |
-| `result` | `string` | `success` on success, and `error` on error |
+| `result` | `string` | `created` on success, and `error` on error |
| `apikey.key` | `string` | The API key |
| `apikey.secret` | `string` | The API secret |
| `apikey.level` | `number` | The privilege level of the API key |
@@ -57,7 +58,7 @@ Returns status code `200` on success, `400` on if the request is malformed, and
```js
-const token = axios.post(
+const apiKey = axios.post(
'https://backend.freesewing.org/apikey/jwt',
{
name: 'My first API key',
@@ -90,6 +91,185 @@ const token = axios.post(
+## Read an API key
+
+Reads an existing API key. Note that the API secret can only be retrieved at
+the moment the API key is created.
+
+
+You need the `admin` role to read API keys of other users
+
+### Endpoints
+
+| Method | Path | Description | Auth |
+| ------ | ---- | ----------- | ---- |
+| | `/apikey/:id/jwt` | Reads an API key | _jwt_ |
+| | `/apikey/:id/key` | Reads an API key | _key_ |
+
+### Parameters
+
+
+| Where | Variable | Type | Description |
+| ----- | ----------- | -------- | ----------- |
+| _url_ | `:id` | `string` | The `key` field of the API key |
+
+
+Returns HTTP status code on success, if
+the request is malformed, if the key is not found,
+and on server error.
+
+| Value | Type | Description |
+| ------------------- | -------- | ----------- |
+| `result` | `string` | `success` on success, and `error` on error |
+| `apikey.key` | `string` | The API key |
+| `apikey.level` | `number` | The privilege level of the API key |
+| `apikey.expiresAt` | `string` | A string representation of the moment the API key expires |
+| `apikey.name` | `string` | The name of the API key |
+| `apikey.userId` | `number` | The ID of the user who created the API key |
+
+
+
+### Example
+
+
+```js
+const keyInfo = axios.get(
+ 'https://backend.freesewing.org/apikey/7ea12968-7758-40b6-8c73-75cc99be762b/jwt',
+ {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ }
+)
+```
+
+
+
+```json
+{
+ result: 'success',
+ apikey: {
+ key: '7ea12968-7758-40b6-8c73-75cc99be762b',
+ level: 3,
+ expiresAt: '2022-11-06T15:57:30.190Z',
+ name: 'My first API key',
+ userId: 61
+ }
+}
+```
+
+
+
+## Read the current API key
+
+Reads the API key with which the current request was authenticated.
+
+### Endpoints
+
+| Method | Path | Description | Auth |
+| ------ | ---- | ----------- | ---- |
+| | `/whoami/key` | Reads the current API key | _key_ |
+
+### Parameters
+
+
+This endpoint takes no parameters
+
+
+Returns status code `200` on success, `400` on if the request is malformed,
+`404` if the key is not found, and `500` on server error.
+
+| Value | Type | Description |
+| ------------------- | -------- | ----------- |
+| `result` | `string` | `success` on success, and `error` on error |
+| `apikey.key` | `string` | The API key |
+| `apikey.level` | `number` | The privilege level of the API key |
+| `apikey.expiresAt` | `string` | A string representation of the moment the API key expires |
+| `apikey.name` | `string` | The name of the API key |
+| `apikey.userId` | `number` | The ID of the user who created the API key |
+
+
+
+### Example
+
+
+```js
+const keyInfo = axios.get(
+ 'https://backend.freesewing.org/whoami/key',
+ {
+ auth: {
+ username: apikey.key,
+ password: apikey.secret,
+ }
+ }
+)
+```
+
+
+
+```json
+{
+ result: 'success',
+ apikey: {
+ key: '7ea12968-7758-40b6-8c73-75cc99be762b',
+ level: 3,
+ expiresAt: '2022-11-06T15:57:30.190Z',
+ name: 'My first API key',
+ userId: 61
+ }
+}
+```
+
+
+
+
+## Remove an API key
+
+Removes an existing API key.
+
+
+You need the `admin` role to remove API keys of other users
+
+
+### Endpoints
+
+| Method | Path | Description | Auth |
+| ------ | ---- | ----------- | ---- |
+| | `/apikey/:id/jwt` | Removes an API key | _jwt_ |
+| | `/apikey/:id/key` | Removes an API key | _key_ |
+
+### Parameters
+
+
+| Where | Variable | Type | Description |
+| ----- | ----------- | -------- | ----------- |
+| _url_ | `:id` | `string` | The `key` field of the API key |
+
+
+Returns HTTP status code on success, if
+the request is malformed, if the key is not found,
+and on server error.
+
+
+### Example
+
+
+```js
+const keyInfo = axios.get(
+ 'https://backend.freesewing.org/apikey/7ea12968-7758-40b6-8c73-75cc99be762b/jwt',
+ {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ }
+)
+```
+
+
+Status code (no content) does not come with a body
+
+
+
## Notes
The following is good to keep in mind when working with API keys:
diff --git a/markdown/dev/reference/backend/api/en.md b/markdown/dev/reference/backend/api/en.md
index 0ac668ad7db..a82a56d56aa 100644
--- a/markdown/dev/reference/backend/api/en.md
+++ b/markdown/dev/reference/backend/api/en.md
@@ -1,5 +1,6 @@
---
-title: REST API
+title: Backend REST API
+linktitle: REST API
---
This is the reference documentation for the FreeSewing backend REST API.
@@ -19,30 +20,35 @@ automation tasks such as creating issues on Github.
## Authentication
-This API is not accessible without authentication.
+Apart from a handlful of API endpoints that are accessible without
+authentication (typically the ones dealing with the signup flow or password
+recovery), this API requires authentication.
-The FreeSewing backend API allows two types of authentication:
+Two different types of authentication are supports:
-- JSON Web Tokens (jwt): This is typically used to authenticate humans in a
+- **JSON Web Tokens** (jwt): This is typically used to authenticate humans in a
browser session.
-- API Keys (key): This is typically used to interact with the API in an
- automated way. Like in a script, or a CI/CD context.
+- **API Keys** (key): This is typically used to interact with the API in an
+ automated way. Like in a script, a CI/CD context, a serverless runner, and so
+ on.
-Apart from the handlful of API endpoints that are accessible without
-authentication (typically the ones dealing with the signup flow), this API has
-a variant for each route depending on what authentication you want to use:
+For each endpoint, the API has a variant depending on what authentication you
+want to use:
- `/some/route/jwt` : Authenticate with JWT
- `/some/route/key` : Authenticate with an API key and secret
### JWT authentication
-The use of JSON Web Tokens ([jwt](https://jwt.io)) is typically used in a browser context where we want to establish a *session*.
+The use of JSON Web Tokens ([jwt](https://jwt.io)) is typically used in a
+browser context where we want to establish a *session*.
-To get a token, you must first authenticate at the `/login` endpoint with username and password.
-You will receive a token in the response.
+To get a token, you must first authenticate at the `/login` endpoint with
+username and password. You will receive a JSON Web Token (jwt) as part of the
+response.
-In subsequent API calls, you must then include this token in the `Authorization` header prefixed by `Bearer`. Liek his:
+In subsequent API calls, you must then include this token in the
+`Authorization` header prefixed by `Bearer`. Like his:
```js
const account = await axios.get(
@@ -57,11 +63,18 @@ const account = await axios.get(
### API key authentication
-The combination API key & secret serves as a username & password for HTTP basic authentication.
-In basic authentication, the password is sent unencrypted, but since the FreeSewing backend is only reachable over a TLS encrypted connection, this is not a problem.
+The combination of API key & secret serves as a username & password for [HTTP
+basic authentication](https://en.wikipedia.org/wiki/Basic_access_authentication).
-On the plus side, sending a username and password with a request is supported pretty much everywhere.
-In addition, there is no need to establish a session first, so this make the entire transation stateless.
+
+In basic authentication, the password is sent
+unencrypted. To guard against this, this API should only be served over a
+connectin encrypted with TLS. (a url starting with `https://`).
+
+
+Sending a username and password with a request like this is supported
+pretty much everywhere. In addition, there is no need to establish a session
+first, so this make the entire transation stateless.
Below is an example using curl:
@@ -96,8 +109,8 @@ The table below lists the priviledge of all levels as well as their correspondin
| `4` | **write all** account data | ✅ | ✅ | ✅ | ✅ |
| `5` | **read** measurements or patterns of **other users** | ❌ | ✅ | ✅ | ✅ |
| `6` | **read all** account data of **other users** | ❌ | ❌ | ✅ | ✅ |
-| `7` | **write** access through **specific support methods** | ❌ | ❌ | ✅ | ✅ |
-| `8` | impersonate other user, full write access | ❌ | ❌ | ❌ | ✅ |
+| `7` | **write** account data of **other users** through **specific support methods** | ❌ | ❌ | ✅ | ✅ |
+| `8` | impersonate other users, **full write access** | ❌ | ❌ | ❌ | ✅ |
## API Routes
diff --git a/markdown/dev/reference/backend/en.md b/markdown/dev/reference/backend/en.md
index 6052a34e0dd..c2972b0084e 100644
--- a/markdown/dev/reference/backend/en.md
+++ b/markdown/dev/reference/backend/en.md
@@ -2,4 +2,57 @@
title: FreeSewing backend
---
-FIXME: Explain what this is about
+The FreeSewing backend handles all user data. Prior to version 3 of FreeSewing,
+the backend was only used internally as the data store for our frontend, the
+FreeSewing.org website.
+
+In version 3, we have rewritten the backend with the explicit goal to offer it
+as a service to users and developers. This allows integration with other tools
+such as hosted instances of our lab, CLI tools, serverless runners, CI/CD
+environments and so on.
+
+In other words, we no longer merely provide our own frontend, you can now also
+use our backend as a service to build your own projects.
+
+## Changes for developers
+
+### Authentication with JWT and API keys
+
+Before version 3, the backend only supported authentication via JSON Web
+Tokens. That's fine for a browser session, but not very handy if you want to
+talk to the API directly.
+
+Since version 3, we support authentication with API keys. Furthermore, we
+allow any FreeSewing user to generate their own API keys.
+
+In other words, if you want to connect to our backend API, you don't need to
+ask us. You can generate your own API key and start right away.
+
+We've made a number of changes to make it easier for external developers and
+contributors to work with our backend.
+
+### Sqlite instead of MongoDB
+
+Our backend used to use MongoDB for storage. Since version 3, we've moved to
+Sqlite which is a file-based database making local development a breeze since
+you don't need to run a local database server.
+
+### Sanity instead of local storage
+
+We now use Sanity and the Sanity API to stored images for users (avatars for
+user accounts and people). Furthermore, we also generate PDFs in the browser
+now so we also don't need storage for that.
+
+As a result, our backend does not need any storage, only access to the Sqlite
+file. This also makes it easier to work with the backend as a developer.
+
+## Use, don't abuse
+
+Our backend API runs in a cloud environment and while we do not charge for
+access to the API, we do need to pay the bills of said cloud provider.
+
+As such, please be mindful of the amount of requests you generate. And if you
+have big plans, please reach out to us to discuss them first.
+
+We will monitor the use of our backend API and we may at any moment decide to
+revoke API keys if we feel the use is beyond what we can or want to support.
diff --git a/sites/backend/src/controllers/apikey.mjs b/sites/backend/src/controllers/apikey.mjs
index 7f593d86404..9a1bf84522f 100644
--- a/sites/backend/src/controllers/apikey.mjs
+++ b/sites/backend/src/controllers/apikey.mjs
@@ -62,3 +62,16 @@ ApikeyController.prototype.whoami = async (req, res, tools) => {
return Apikey.sendResponse(res)
}
+
+/*
+ * Remove API key
+ *
+ * This is the endpoint that handles removal of API keys/tokens
+ * See: https://freesewing.dev/reference/backend/api/apikey
+ */
+ApikeyController.prototype.delete = async (req, res, tools) => {
+ const Apikey = new ApikeyModel(tools)
+ await Apikey.removeIfAllowed({ id: req.params.id }, req.user)
+
+ return Apikey.sendResponse(res)
+}
diff --git a/sites/backend/src/models/apikey.mjs b/sites/backend/src/models/apikey.mjs
index 2f4e6a51e9a..c537be58e41 100644
--- a/sites/backend/src/models/apikey.mjs
+++ b/sites/backend/src/models/apikey.mjs
@@ -25,7 +25,8 @@ ApikeyModel.prototype.setResponse = function (status = 200, error = false, data
...data,
},
}
- if (status > 201) {
+ if (status === 201) this.response.body.result = 'created'
+ else if (status > 201) {
this.response.body.error = error
this.response.body.result = 'error'
this.error = true
@@ -68,12 +69,34 @@ ApikeyModel.prototype.readIfAllowed = async function (where, user) {
})
}
+ApikeyModel.prototype.removeIfAllowed = async function (where, user) {
+ if (!this.User.authenticatedUser) await this.User.loadAuthenticatedUser(user)
+ await this.read(where)
+ if (!this.record) return this.setResponse(404, 'apikeyNotFound')
+ if (this.record.userId !== this.User.authenticatedUser.id) {
+ // Not own key - only admin can do that
+ if (this.User.authenticatedUser.role !== 'admin') {
+ return this.setResponse(400, 'permissionLackingToRemoveOtherApiKey')
+ }
+ }
+ await this.remove(where)
+
+ return this.setResponse(204)
+}
+
ApikeyModel.prototype.read = async function (where) {
this.record = await this.prisma.apikey.findUnique({ where })
return this
}
+ApikeyModel.prototype.remove = async function (where) {
+ await this.prisma.apikey.delete({ where })
+ this.record = false
+
+ return this
+}
+
ApikeyModel.prototype.create = async function ({ body, user }) {
if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing')
if (!body.name) return this.setResponse(400, 'nameMissing')
@@ -101,7 +124,7 @@ ApikeyModel.prototype.create = async function ({ body, user }) {
name: body.name,
level: body.level,
secret: asJson(hashPassword(secret)),
- userId: user._id,
+ userId: user._id || user.userId,
},
})
} catch (err) {
@@ -109,7 +132,7 @@ ApikeyModel.prototype.create = async function ({ body, user }) {
return this.setResponse(500, 'createApikeyFailed')
}
- return this.setResponse(200, 'success', {
+ return this.setResponse(201, 'created', {
apikey: {
key: this.record.id,
secret,
diff --git a/sites/backend/src/routes/apikey.mjs b/sites/backend/src/routes/apikey.mjs
index 582169dfb06..b22eeb02643 100644
--- a/sites/backend/src/routes/apikey.mjs
+++ b/sites/backend/src/routes/apikey.mjs
@@ -27,4 +27,12 @@ export function apikeyRoutes(tools) {
app.get('/whoami/key', passport.authenticate(...bsc), (req, res) =>
Apikey.whoami(req, res, tools)
)
+
+ // Remove Apikey
+ app.delete('/apikey/:id/jwt', passport.authenticate(...jwt), (req, res) =>
+ Apikey.delete(req, res, tools)
+ )
+ app.delete('/apikey/:id/key', passport.authenticate(...bsc), (req, res) =>
+ Apikey.delete(req, res, tools)
+ )
}
diff --git a/sites/backend/tests/user.test.mjs b/sites/backend/tests/user.test.mjs
index d5aab549301..f476141bdaa 100644
--- a/sites/backend/tests/user.test.mjs
+++ b/sites/backend/tests/user.test.mjs
@@ -287,10 +287,10 @@ describe(`${user} Signup flow and authentication`, () => {
expiresIn: 60,
})
.end((err, res) => {
- expect(res.status).to.equal(200)
+ expect(res.status).to.equal(201)
expect(res.type).to.equal('application/json')
expect(res.charset).to.equal('utf-8')
- expect(res.body.result).to.equal(`success`)
+ expect(res.body.result).to.equal(`created`)
expect(typeof res.body.apikey.key).to.equal('string')
expect(typeof res.body.apikey.secret).to.equal('string')
expect(typeof res.body.apikey.expiresAt).to.equal('string')
@@ -300,6 +300,30 @@ describe(`${user} Signup flow and authentication`, () => {
})
})
+ step(`${user} Create API Key with KEY`, (done) => {
+ chai
+ .request(config.api)
+ .post('/apikey/key')
+ .auth(store.apikey.key, store.apikey.secret)
+ .send({
+ name: 'Test API key with key',
+ level: 4,
+ expiresIn: 60,
+ })
+ .end((err, res) => {
+ expect(res.status).to.equal(201)
+ expect(res.type).to.equal('application/json')
+ expect(res.charset).to.equal('utf-8')
+ expect(res.body.result).to.equal(`created`)
+ expect(typeof res.body.apikey.key).to.equal('string')
+ expect(typeof res.body.apikey.secret).to.equal('string')
+ expect(typeof res.body.apikey.expiresAt).to.equal('string')
+ expect(res.body.apikey.level).to.equal(4)
+ store.apikeykey = res.body.apikey
+ done()
+ })
+ })
+
step(`${user} Read API Key with KEY (whoami)`, (done) => {
chai
.request(config.api)
@@ -320,7 +344,7 @@ describe(`${user} Signup flow and authentication`, () => {
chai
.request(config.api)
.get(`/apikey/${store.apikey.key}/key`)
- .auth(store.apikey.key, store.apikey.secret)
+ .auth(store.apikeykey.key, store.apikeykey.secret)
.end((err, res) => {
expect(res.status).to.equal(200)
expect(res.type).to.equal('application/json')
@@ -335,7 +359,7 @@ describe(`${user} Signup flow and authentication`, () => {
step(`${user} Read API Key with JWT`, (done) => {
chai
.request(config.api)
- .get(`/apikey/${store.apikey.key}/jwt`)
+ .get(`/apikey/${store.apikeykey.key}/jwt`)
.set('Authorization', 'Bearer ' + store.token)
.end((err, res) => {
expect(res.status).to.equal(200)
@@ -343,7 +367,18 @@ describe(`${user} Signup flow and authentication`, () => {
expect(res.charset).to.equal('utf-8')
expect(res.body.result).to.equal(`success`)
const checks = ['key', 'level', 'expiresAt', 'name', 'userId']
- checks.forEach((i) => expect(res.body.apikey[i]).to.equal(store.apikey[i]))
+ checks.forEach((i) => expect(res.body.apikey[i]).to.equal(store.apikeykey[i]))
+ done()
+ })
+ })
+
+ step(`${user} Remove API Key with KEY`, (done) => {
+ chai
+ .request(config.api)
+ .delete(`/apikey/${store.apikeykey.key}/key`)
+ .auth(store.apikeykey.key, store.apikeykey.secret)
+ .end((err, res) => {
+ expect(res.status).to.equal(204)
done()
})
})
diff --git a/sites/shared/components/mdx/index.js b/sites/shared/components/mdx/index.js
index 9c4d3d97650..7ad7b6fd10b 100644
--- a/sites/shared/components/mdx/index.js
+++ b/sites/shared/components/mdx/index.js
@@ -7,8 +7,52 @@ import { Tab, Tabs } from './tabs.js'
import Example from './example.js'
import Examples from './examples.js'
+const methodClasses = {
+ get: 'bg-green-600 text-white',
+ post: 'bg-sky-600 text-white',
+ put: 'bg-orange-500 text-white',
+ delete: 'bg-red-600 text-white',
+}
+
+const Method = (props) => {
+ let method = false
+ for (const m in methodClasses) {
+ if (!method && props[m]) method = m.toUpperCase()
+ }
+
+ return (
+
+ {method}
+
+ )
+}
+
+const statusClasses = {
+ 2: 'bg-green-600 text-white',
+ 4: 'bg-orange-500 text-white',
+ 5: 'bg-red-600 text-white',
+}
+
+const StatusCode = ({ status }) => {
+ return (
+
+ {status}
+
+ )
+}
+
const mdxCustomComponents = (app = false) => ({
// Custom components
+ Method,
+ StatusCode,
Comment: (props) => ,
Fixme: (props) => ,
Link: (props) => ,
diff --git a/sites/shared/config/tailwind-force.html b/sites/shared/config/tailwind-force.html
index 173f12b0728..b8834598b90 100644
--- a/sites/shared/config/tailwind-force.html
+++ b/sites/shared/config/tailwind-force.html
@@ -18,3 +18,4 @@
+