├── setup.js ├── frontend ├── public │ ├── robots.txt │ └── font │ │ ├── Inter-italic.var.woff2 │ │ ├── Inter-roman.var.woff2 │ │ └── inter.css ├── README.md ├── .gitignore ├── postcss.config.js ├── jsconfig.json ├── .github │ └── dependabot.yml ├── src │ ├── views │ │ ├── NotFound.vue │ │ ├── About.vue │ │ ├── auth │ │ │ ├── login.vue │ │ │ └── signup.vue │ │ ├── dashboard │ │ │ ├── objects │ │ │ │ ├── relationship_autocomplete.vue │ │ │ │ ├── list.vue │ │ │ │ └── edit.vue │ │ │ ├── models │ │ │ │ ├── list.vue │ │ │ │ └── edit.vue │ │ │ ├── base.vue │ │ │ ├── keys │ │ │ │ ├── edit.vue │ │ │ │ └── list.vue │ │ │ └── presets.vue │ │ └── Home.vue │ ├── components │ │ ├── html_editor.vue │ │ ├── ButtonRepo.vue │ │ └── loading_overlay.vue │ ├── main.js │ ├── routes.js │ ├── App.vue │ ├── api.js │ └── tailwind.css ├── vite.config.js ├── tailwind.config.js ├── package.json └── index.html ├── .gitignore ├── src ├── lib │ ├── live.mjs │ ├── timing.js │ ├── objects.js │ ├── cache.js │ ├── writer.mjs │ └── table.js ├── request.js ├── api_v1 │ ├── auth │ │ ├── analytics.js │ │ └── accounts.js │ └── models │ │ ├── graphql.js │ │ ├── models.js │ │ ├── presets.js │ │ └── content.js ├── index.js ├── init.js ├── router.js └── tsndr_router.js ├── package.json ├── wrangler.toml ├── wrangler.template.toml ├── test └── index.spec.js ├── npm-debug.log ├── README.md └── LICENSE.md /setup.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist 3 | node_modules 4 | .secrets.env 5 | .mf -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Horseman, the headless CMS 2 | 3 | This is the frontend code for Horseman -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | debug.log 7 | .idea -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/public/font/Inter-italic.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AggressivelyMeows/Horseman/HEAD/frontend/public/font/Inter-italic.var.woff2 -------------------------------------------------------------------------------- /frontend/public/font/Inter-roman.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AggressivelyMeows/Horseman/HEAD/frontend/public/font/Inter-roman.var.woff2 -------------------------------------------------------------------------------- /frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "jsx": "preserve", 5 | "paths": { 6 | "@/*": ["src/*"] 7 | } 8 | }, 9 | "exclude": ["node_modules", "dist"] 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/live.mjs: -------------------------------------------------------------------------------- 1 | // A durable object store for Horseman Channels 2 | // A system for sending and receiving messages alongside object updates 3 | // such as OBJECT.CREATE, OBJECT.MODIFY, OBJECT.DELETE 4 | 5 | // TODO: Build the DurableObject -------------------------------------------------------------------------------- /frontend/.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: weekly 7 | time: '03:00' 8 | open-pull-requests-limit: 10 9 | versioning-strategy: increase 10 | -------------------------------------------------------------------------------- /frontend/src/views/NotFound.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import { resolve } from 'path' 4 | 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | resolve: { 8 | alias: { 9 | '@': resolve(__dirname, 'src'), 10 | }, 11 | }, 12 | server: { 13 | open: true, 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /frontend/src/components/html_editor.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | -------------------------------------------------------------------------------- /frontend/public/font/inter.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Inter var'; 3 | font-weight: 100 900; 4 | font-display: swap; 5 | font-style: normal; 6 | font-named-instance: 'Regular'; 7 | src: url('Inter-roman.var.woff2') format('woff2'); 8 | } 9 | @font-face { 10 | font-family: 'Inter var'; 11 | font-weight: 100 900; 12 | font-display: swap; 13 | font-style: italic; 14 | font-named-instance: 'Italic'; 15 | src: url('Inter-italic.var.woff2') format('woff2'); 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/components/ButtonRepo.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /src/request.js: -------------------------------------------------------------------------------- 1 | export async function handleRequest(request) { 2 | // Parse the request's url so we can get the path 3 | const url = new URL(request.url); 4 | // Get the path's current count 5 | const currentValue = await COUNTER_NAMESPACE.get(url.pathname); 6 | // Increment the path's count, defaulting it to 0 7 | const newValue = (parseInt(currentValue ?? "0") + 1).toString(); 8 | // Store the new count 9 | await COUNTER_NAMESPACE.put(url.pathname, newValue); 10 | // Return the new count 11 | return new Response(newValue); 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/timing.js: -------------------------------------------------------------------------------- 1 | const all_timers = [] 2 | 3 | class ServerTiming { 4 | constructor(name, desc) { 5 | this.name = name 6 | this.desc = desc 7 | this.start = new Date() 8 | this.end = new Date() 9 | all_timers.push(this) 10 | } 11 | 12 | finish() { 13 | this.end = new Date() 14 | } 15 | 16 | generate_header() { 17 | const time = this.end.getTime() - this.start.getTime() 18 | return `${this.name};dur=${time}` 19 | } 20 | } 21 | 22 | export { 23 | all_timers, 24 | ServerTiming 25 | } -------------------------------------------------------------------------------- /src/api_v1/auth/analytics.js: -------------------------------------------------------------------------------- 1 | import { router } from '../../router.js' 2 | import { Table } from '../../lib/table.js' 3 | 4 | router.get(router.version + '/statistics', router.requires_auth, async (req, res) => { 5 | 6 | console.log(`https://api.countapi.xyz/get/horseman.ceru.dev/acc-${req.user.id}-read`) 7 | 8 | const cost_arr = await Promise.all([ 9 | fetch(`https://api.countapi.xyz/get/horseman.ceru.dev/acc-${req.user.id}-read`).then(r=>r.json()), 10 | fetch(`https://api.countapi.xyz/get/horseman.ceru.dev/acc-${req.user.id}-write`).then(r=>r.json()), 11 | ]) 12 | 13 | res.body = { 14 | success: true, 15 | costs: { 16 | reads: cost_arr[0], 17 | writes: cost_arr[1] 18 | } 19 | } 20 | }) -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import './tailwind.css' 3 | import App from './App.vue' 4 | import API from './api.js' 5 | import { routes } from './routes.js' 6 | import { createRouter, createWebHistory } from 'vue-router' 7 | import Oruga from '@oruga-ui/oruga-next' 8 | import '@oruga-ui/oruga-next/dist/oruga.css' 9 | import Notifications from '@kyvg/vue3-notification' 10 | import { notify } from "@kyvg/vue3-notification" 11 | 12 | const app = createApp(App) 13 | 14 | const router = createRouter({ 15 | history: createWebHistory(), 16 | routes, 17 | }) 18 | 19 | app.use(router) 20 | app.use(Oruga) 21 | app.use(Notifications) 22 | 23 | app.config.globalProperties.$api = new API() 24 | app.config.globalProperties.$notify = notify 25 | 26 | window.$api = app.config.globalProperties.$api 27 | 28 | app.mount('#app') 29 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme') 2 | const colors = require('tailwindcss/colors') 3 | 4 | colors.neutral['850'] = '#1F1F1F' 5 | 6 | /** @type {import("@types/tailwindcss/tailwind-config").TailwindConfig } */ 7 | module.exports = { 8 | content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], 9 | theme: { 10 | extend: { 11 | colors: { 12 | gray: colors.neutral, 13 | 'primary': colors.fuchsia, 14 | }, 15 | fontFamily: { 16 | sans: ['"Inter var"', ...defaultTheme.fontFamily.sans], 17 | mono: ['"Source Code Pro"', ...defaultTheme.fontFamily.mono], 18 | }, 19 | }, 20 | }, 21 | plugins: [ 22 | require('@tailwindcss/forms'), 23 | require('@tailwindcss/typography'), 24 | require('@tailwindcss/line-clamp'), 25 | require('@tailwindcss/aspect-ratio'), 26 | require("a17t") 27 | ], 28 | } 29 | -------------------------------------------------------------------------------- /src/api_v1/models/graphql.js: -------------------------------------------------------------------------------- 1 | import { apolloClient } from 'apollo-server-cloudflare' 2 | import { graphqlCloudflare } from 'apollo-server-cloudflare/dist/cloudflareApollo' 3 | 4 | import { router } from '../../router.js' 5 | import { Table } from '../../lib/table.js' 6 | 7 | router.get(router.version + '/models/:modelID/gql', router.requires_auth, async (req, res) => { 8 | const model_table = new Table( 9 | req.user.id, 10 | 'models' 11 | ) 12 | 13 | const all_models = await model_table.list( 14 | '__all_objects_index', 15 | 0 16 | ) 17 | 18 | const resolvers = { 19 | Query: {}, 20 | } 21 | 22 | console.log(all_models) 23 | 24 | resolvers.Query[req.params.modelID] = async (_source, search, { dataSources }) => { 25 | return await dataSources.router.handle(new Request( 26 | `http://t.co/v1/models/${req.params.modelID}/objects?key=${req.key}` 27 | )) 28 | } 29 | 30 | 31 | const server = new ApolloServer({ 32 | typeDefs, 33 | resolvers, 34 | introspection: true, 35 | dataSources 36 | }) 37 | }) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "miniflare-esbuild-ava", 3 | "version": "1.0.0", 4 | "description": "Example project using Miniflare, esbuild and AVA", 5 | "type": "module", 6 | "main": "./dist/index.js", 7 | "scripts": { 8 | "deploy": "npx wrangler@beta publish", 9 | "build": "node ./build.js", 10 | "dev": "npx miniflare@latest --watch --debug --env .secrets.env", 11 | "test": "npm run build && ava --verbose test/*.spec.js" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "BSL", 16 | "devDependencies": { 17 | "ava": "^3.15.0", 18 | "esbuild": "^0.13.2", 19 | "miniflare": "^2.0.0" 20 | }, 21 | "dependencies": { 22 | "@cfworker/sentry": "^1.12.2", 23 | "@cloudflare/worker-sentry": "^1.0.0", 24 | "@tsndr/cloudflare-worker-router": "^1.3.2", 25 | "apollo-server-cloudflare": "^3.9.0", 26 | "flexsearch": "^0.7.21", 27 | "json-mask": "^2.0.0", 28 | "nanoid": "^3.3.4", 29 | "slugify": "^1.6.5", 30 | "wildcard-match": "^5.1.2", 31 | "wildcard-regex": "^3.0.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/views/About.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 30 | -------------------------------------------------------------------------------- /src/lib/objects.js: -------------------------------------------------------------------------------- 1 | 2 | const get_object = async (userID, model, index, search) => { 3 | // Finds an object from the database 4 | const key = `${userID}:${model}:${index}` 5 | 6 | const data = await INDEXKV.get(key, { type: 'json', cacheTtl: 120 }) 7 | 8 | if (!data) { 9 | return { 10 | success: false, 11 | error: 'This object/index combo does not exist', 12 | code: 'IDX_NOT_FOUND' 13 | } 14 | } 15 | 16 | const objectID = data.find(o => { 17 | // o is in the format of [ "objectID", "indexed string result" ] 18 | return o[1] == search 19 | }) 20 | 21 | if (!objectID) { 22 | return null 23 | } 24 | 25 | return { 26 | success: true, 27 | object: await OBJECTKV.get(`${userID}:${model}:${objectID}`, { type: 'json', cacheTtl: 420 }) 28 | } 29 | } 30 | 31 | const create_schema = async (userID, model, spec) => { 32 | 33 | } 34 | 35 | const create_object = (userID, model, index, search) => { 36 | // Finds an object from the database 37 | const key = `${userID}:${model}:${index}` 38 | 39 | const data = await INDEXKV.get(key, { type: 'json', cacheTtl: 120 }) 40 | } 41 | 42 | export { 43 | get_object, 44 | create_object 45 | } -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-vue3-tailwind-starter", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite --host", 6 | "build": "vite build", 7 | "serve": "vite preview" 8 | }, 9 | "dependencies": { 10 | "@kyvg/vue3-notification": "^2.3.4", 11 | "@oruga-ui/oruga-next": "^0.5.5", 12 | "@vueup/vue-quill": "^1.0.0-beta.8", 13 | "a17t": "^0.10.1", 14 | "axios": "^0.27.2", 15 | "cobe": "^0.5.0", 16 | "csv": "^6.1.3", 17 | "marked": "^4.0.17", 18 | "nanoevents": "^7.0.1", 19 | "quill-html-edit-button": "^2.2.12", 20 | "random-gradient": "0.0.2", 21 | "slugify": "^1.6.5", 22 | "vue": "^3.2.36", 23 | "vue-notification": "^1.3.20", 24 | "vue-router": "^4.0.15" 25 | }, 26 | "devDependencies": { 27 | "@headlessui/vue": "^1.6.4", 28 | "@heroicons/vue": "^1.0.6", 29 | "@tailwindcss/aspect-ratio": "^0.4.0", 30 | "@tailwindcss/forms": "^0.5.2", 31 | "@tailwindcss/line-clamp": "^0.4.0", 32 | "@tailwindcss/typography": "^0.5.2", 33 | "@types/tailwindcss": "^3.0.10", 34 | "@vitejs/plugin-vue": "^2.3.3", 35 | "autoprefixer": "^10.4.7", 36 | "prettier-plugin-tailwindcss": "^0.1.11", 37 | "tailwindcss": "^3.0.24", 38 | "vite": "^2.9.9" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/routes.js: -------------------------------------------------------------------------------- 1 | import Home from './views/Home.vue' 2 | import About from './views/About.vue' 3 | import NotFound from './views/NotFound.vue' 4 | 5 | export const routes = [ 6 | { path: '/', component: Home, meta: { title: 'Home' } }, 7 | { path: '/signup', component: () => import('./views/auth/signup.vue') }, 8 | { path: '/login', component: () => import('./views/auth/login.vue') }, 9 | { path: '/dashboard', component: () => import('./views/dashboard/base.vue'), children: [ 10 | { path: '/dashboard/presets', component: () => import('./views/dashboard/presets.vue') }, 11 | { path: '/dashboard/models', component: () => import('./views/dashboard/models/list.vue')}, 12 | { path: '/dashboard/models/:modelID', component: () => import('./views/dashboard/models/edit.vue')}, 13 | { path: '/dashboard/access/keys', component: () => import('./views/dashboard/keys/list.vue')}, 14 | { path: '/dashboard/access/keys/:keyID', component: () => import('./views/dashboard/keys/edit.vue')}, 15 | { path: '/dashboard/models/:modelID/objects', component: () => import('./views/dashboard/objects/list.vue')}, 16 | { path: '/dashboard/models/:modelID/editor/:objectID', component: () => import('./views/dashboard/objects/edit.vue')}, 17 | ] }, 18 | 19 | { path: '/:path(.*)', component: NotFound }, 20 | ] 21 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "horseman" 2 | compatibility_date = "2022-03-30" 3 | account_id = "2dcc5a4f486a50625b4167e90d113158" 4 | workers_dev = false 5 | 6 | # 7 | kv_namespaces = [ 8 | { binding = "INDEXKV", id = "cb0bc6abf6f44cdab5fd417cd62232e3", preview_id = "cb0bc6abf6f44cdab5fd417cd62232e3" }, 9 | { binding = "CONTENTKV", id = "a5b404d4836e4594894fc6ab15902568", preview_id = "a5b404d4836e4594894fc6ab15902568" }, 10 | ] 11 | 12 | services = [ 13 | { binding = "PASSWORD_HASHING", service = "password-hashing", environment = "production" } 14 | ] 15 | 16 | 17 | experimental_services = [ 18 | { name = "PASSWORD_HASHING", service = "password-hashing", environment = "production" } 19 | ] 20 | 21 | [miniflare.mounts] 22 | password-hashing = "../password-hashing-worker" 23 | 24 | [vars] 25 | HORSEMAN_VERSION = "1.0.0" 26 | ALLOW_SIGNUPS = true # Set to false to disable signups. 27 | 28 | # EVERYTHING BELOW THIS LINE SHOULDNT NEED TO BE CHANGED. 29 | # ------------------------------------------------------ 30 | 31 | [durable_objects] 32 | bindings = [ 33 | { name = "IndexWriter", class_name = "WriterDO" } 34 | ] 35 | 36 | [[migrations]] 37 | tag = "v1" # Should be unique for each entry 38 | new_classes = ["WriterDO"] 39 | 40 | [miniflare] 41 | kv_persist = true # Defaults to ./.mf/kv 42 | 43 | [build] 44 | command = "npm run build" 45 | 46 | [build.upload] 47 | format = "modules" 48 | main='index.js' 49 | 50 | -------------------------------------------------------------------------------- /wrangler.template.toml: -------------------------------------------------------------------------------- 1 | name = "horseman" 2 | compatibility_date = "2022-03-30" 3 | type = "javascript" 4 | account_id = "" 5 | workers_dev = false 6 | 7 | kv_namespaces = [ 8 | { binding = "INDEXKV", id = "", preview_id = "" }, 9 | { binding = "CONTENTKV", id = "", preview_id = "" }, 10 | ] 11 | 12 | [vars] 13 | HORSEMAN_VERSION = "1.0.0" 14 | # You need to deploy this Worker (https://github.com/AggressivelyMeows/password-hashing) to your account. 15 | # MAKE SURE TO RUN `npm run generate` TO GET AN ACCESS KEY. 16 | # Once done, put the domain its running under below: 17 | PASSWORD_HASHER_DOMAIN = "password-hashing.sponsus.workers.dev" # The domain of the password hasher service. 18 | 19 | # Do not forget to run: 20 | # npx wrangler secret put PASSWORD_ACCESS_KEY 21 | # Then paste the access key generated by the password hashing Worker. 22 | 23 | ALLOW_SIGNUPS = true # Set to false to disable signups. 24 | 25 | # EVERYTHING BELOW THIS LINE SHOULDNT NEED TO BE CHANGED. 26 | # ------------------------------------------------------ 27 | 28 | [durable_objects] 29 | bindings = [ 30 | { name = "IndexWriter", class_name = "WriterDO" } 31 | ] 32 | 33 | [[migrations]] 34 | tag = "v1" # Should be unique for each entry 35 | new_classes = ["WriterDO"] 36 | 37 | [miniflare] 38 | kv_persist = true # Defaults to ./.mf/kv 39 | 40 | [build] 41 | command = "npm run build" 42 | [build.upload] 43 | format = "modules" 44 | main='index.js' -------------------------------------------------------------------------------- /frontend/src/views/auth/login.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | -------------------------------------------------------------------------------- /frontend/src/views/auth/signup.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Horseman 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /test/index.spec.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { Miniflare } from "miniflare"; 3 | 4 | test.beforeEach((t) => { 5 | // Create a new Miniflare environment for each test 6 | const mf = new Miniflare({ 7 | // Autoload configuration from `.env`, `package.json` and `wrangler.toml` 8 | envPath: true, 9 | packagePath: true, 10 | wranglerConfigPath: true, 11 | // We don't want to rebuild our worker for each test, we're already doing 12 | // it once before we run all tests in package.json, so disable it here. 13 | // This will override the option in wrangler.toml. 14 | buildCommand: undefined, 15 | }); 16 | t.context = { mf }; 17 | }); 18 | 19 | test("increments path count for new paths", async (t) => { 20 | // Get the Miniflare instance 21 | const { mf } = t.context; 22 | // Dispatch a fetch event to our worker 23 | const res = await mf.dispatchFetch("http://localhost:8787/a"); 24 | // Check the count is "1" as this is the first time we've been to this path 25 | t.is(await res.text(), "1"); 26 | }); 27 | 28 | test("increments path count for existing paths", async (t) => { 29 | // Get the Miniflare instance 30 | const { mf } = t.context; 31 | // Get the counter KV namespace 32 | const ns = await mf.getKVNamespace("COUNTER_NAMESPACE"); 33 | // Set an initial count of 5 34 | await ns.put("/a", "5"); 35 | // Dispatch a fetch event to our worker 36 | const res = await mf.dispatchFetch("http://localhost:8787/a"); 37 | // Check returned count is now "6" 38 | t.is(await res.text(), "6"); 39 | // Check KV count is now "6" too 40 | t.is(await ns.get("/a"), "6"); 41 | }); 42 | -------------------------------------------------------------------------------- /frontend/src/views/dashboard/objects/relationship_autocomplete.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 66 | -------------------------------------------------------------------------------- /src/lib/cache.js: -------------------------------------------------------------------------------- 1 | 2 | export class Cache { 3 | constructor () {} 4 | 5 | async read(id, callback) { 6 | // fires callback if the object is not in the cache, putting the returned value into the cache in its place. 7 | let cache = caches.default 8 | const req = new Request(`https://ceru.dev/cache/${id}`) 9 | 10 | let res = await cache.match(req) 11 | 12 | if (!res) { 13 | log(`[CACHE] [${id}] MISS`) 14 | 15 | let value 16 | 17 | try { 18 | value = await callback() 19 | } catch (e) { 20 | return null 21 | } 22 | 23 | res = value 24 | 25 | if (res === null) { 26 | // Dont cache a null value 27 | return null 28 | } 29 | 30 | await this.write( 31 | id, 32 | value 33 | ) 34 | } else { 35 | increase_cost('cache_read') 36 | log(`[CACHE] [${id}] HIT`) 37 | res = await res.json() 38 | } 39 | 40 | return res 41 | } 42 | 43 | async write(id, data, opt) { 44 | const options = Object.assign( 45 | {}, 46 | opt 47 | ) 48 | 49 | console.log(`[CACHEdasdasdasdasdasdasdsadasdasdasdasdasda] Writing ${id}`) 50 | 51 | let cache = caches.default 52 | const req = new Request(`https://ceru.dev/cache/${id}`) 53 | const res = new Response( 54 | JSON.stringify(data), 55 | { 56 | headers: {'Cache-Control': 'max-age=860000'} 57 | } 58 | ) 59 | 60 | await cache.put(req, res) 61 | } 62 | 63 | async delete(id) { 64 | let cache = caches.default 65 | const req = new Request(`https://ceru.dev/cache/${id}`) 66 | 67 | console.log(`[CACHEasdasdasdasdasdasdasdsadas] Deleting ${id}`) 68 | 69 | //ctx.waitUntil(async () => { 70 | await new Promise(r => setTimeout(r, 750)) 71 | await cache.delete(req) 72 | console.log(`[CACHE] Deleted ${id}`) 73 | } 74 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { router } from './router.js' 2 | import { captureError } from '@cfworker/sentry' 3 | import { all_timers } from './lib/timing.js' 4 | 5 | export { WriterDO } from './lib/writer.mjs' 6 | 7 | import './api_v1/auth/accounts.js' 8 | import './api_v1/models/models.js' 9 | import './api_v1/models/content.js' 10 | import './api_v1/models/presets.js' 11 | // import './api_v1/models/graphql.js' 12 | import './init.js' 13 | 14 | export default { 15 | async fetch(request, env, ctx) { 16 | // store the global request variables for use in other parts of the API 17 | globalThis.cloudflare = request.cf 18 | globalThis.request = request 19 | globalThis.env = env 20 | globalThis.ctx = ctx 21 | 22 | globalThis.cost = { 23 | read: 0, // Times we asked KV for data 24 | write: 0, // Times we wrote to KV 25 | cache_read: 0 // Times we found a cached result 26 | } 27 | 28 | const log = [] 29 | 30 | globalThis.increase_cost = (t) => { 31 | globalThis.cost[t] += 1 32 | } 33 | 34 | globalThis.log = (l) => { 35 | console.log(l) 36 | log.push(l) 37 | } 38 | 39 | router.corsConfig.allowOrigin = '*' 40 | router.corsConfig.allowHeaders = 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Content-Length, x-cost, x-log, Server-Timing' 41 | 42 | let resp 43 | 44 | try { 45 | resp = await router.handle(request) 46 | } catch (e) { 47 | 48 | const { event_id, posted } = captureError( 49 | 'https://9292f7a98bf841efb933169ad404dfae@o225929.ingest.sentry.io/6531416', 50 | 'production', 51 | '0', 52 | e, 53 | request, 54 | '' 55 | ) 56 | 57 | throw e 58 | } 59 | 60 | const response = new Response(resp.body, resp) 61 | 62 | response.headers.set('X-Cost', JSON.stringify(cost)) 63 | response.headers.set('X-Log', JSON.stringify(log)) 64 | response.headers.set('Access-Control-Expose-Headers', router.corsConfig.allowHeaders) 65 | 66 | return response 67 | } 68 | } 69 | 70 | -------------------------------------------------------------------------------- /frontend/src/components/loading_overlay.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 53 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 48 | -------------------------------------------------------------------------------- /frontend/src/views/dashboard/models/list.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | -------------------------------------------------------------------------------- /frontend/src/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { reactive } from 'vue' 3 | import { createNanoEvents } from 'nanoevents' 4 | import { notify } from "@kyvg/vue3-notification" 5 | import * as marked from 'marked' 6 | 7 | export default class API { 8 | constructor () { 9 | this.user_token = localStorage.token || '' 10 | 11 | this.state = reactive({ 12 | reads: 0, 13 | writes: 0, 14 | cache_hits: 0 15 | }) 16 | 17 | this.api_url = `https://${ import.meta.env.PROD ? 'horseman.ceru.dev' : 'api-dev.constellations.tech' }/v1` 18 | 19 | this.events = createNanoEvents() 20 | this.marked = marked 21 | 22 | this.axios = this.create_axios() 23 | 24 | if (this.user_token) { 25 | this.authorize(this.user_token) 26 | } 27 | } 28 | 29 | create_axios() { 30 | return axios.create({ 31 | baseURL: this.api_url, 32 | timeout: 10000, 33 | headers: {'Authorization': `User ${this.user_token}`} 34 | }) 35 | } 36 | 37 | process_request(data) { 38 | const stats = JSON.parse(data.headers['x-cost']) 39 | 40 | this.state.reads = this.state.reads + stats.read 41 | this.state.writes = this.state.writes + stats.write 42 | this.state.cache_hits = this.state.cache_hits + stats.cache_read 43 | } 44 | 45 | async get(route) { 46 | const data = await this.axios.get(route) 47 | this.process_request(data) 48 | 49 | return data 50 | } 51 | 52 | async post(route, body) { 53 | const data = await this.axios.post( 54 | route, 55 | body 56 | ) 57 | this.process_request(data) 58 | 59 | return data 60 | } 61 | 62 | async delete(route, body) { 63 | const data = await this.axios.delete(route) 64 | this.process_request(data) 65 | 66 | return data 67 | } 68 | 69 | authorize(token) { 70 | this.user_token = token 71 | localStorage.token = token 72 | this.axios = this.create_axios() 73 | this.get(`/auth/@me`).then(resp => { 74 | this.state.user = resp.data.user 75 | }) 76 | } 77 | 78 | error_notification(error_response) { 79 | if ('response' in error_response) { 80 | notify({ 81 | title: `We're sorry, there was an error`, 82 | text: `${error_response.response.status} - ${error_response.response.data.error}`, 83 | type: 'error' 84 | }) 85 | } else { 86 | throw error_response 87 | } 88 | 89 | 90 | 91 | } 92 | } -------------------------------------------------------------------------------- /src/lib/writer.mjs: -------------------------------------------------------------------------------- 1 | // The writer is a durable object tasked with ensuring that all writes are sequencial 2 | // You might ask why? 3 | // Well what happens if two writes come in at the same time? 4 | // Our indexes drop one of the objects as both Workers get the index, append the object, and write it again 5 | // This durable objects job is to sit and just write the indexes and objects to the DB. 6 | 7 | import Router from '@tsndr/cloudflare-worker-router' 8 | 9 | export class WriterDO { 10 | constructor(state, env) { 11 | this.env = env 12 | this.state = state 13 | 14 | this.tasks = [] 15 | } 16 | 17 | async fetch(req) { 18 | const router = new Router() 19 | 20 | router.cors() 21 | 22 | router.post(`/v1/write`, async (req, res) => { 23 | const prefix = req.body.prefix 24 | const objectID = req.body.objectID 25 | const value = req.body.value 26 | 27 | const resp = await this.state.blockConcurrencyWhile(async () => { 28 | // prefix should be userID:tablename:field 29 | let idx = await this.env.INDEXKV.get(prefix + ':index', { type: 'json' }) 30 | 31 | if (!idx) { 32 | // we need to make an index for this object 33 | idx = [] 34 | } 35 | 36 | const is_existing = idx.filter(comp => comp[1] == objectID) 37 | 38 | if (is_existing.length > 0) { 39 | idx = idx.map(comp => { 40 | if (comp[1] == objectID) { 41 | comp[0] = value 42 | } 43 | return comp 44 | }) 45 | } else { 46 | idx.push([ value, objectID ]) 47 | } 48 | 49 | await this.env.INDEXKV.put(prefix + ':index', JSON.stringify( idx )) 50 | 51 | return idx 52 | }) 53 | 54 | res.body = { 55 | success: true, 56 | index: resp 57 | } 58 | }) 59 | 60 | router.post(`/v1/delete`, async (req, res) => { 61 | const prefix = req.body.prefix 62 | const objectID = req.body.objectID 63 | 64 | const index = await this.state.blockConcurrencyWhile(async () => { 65 | // prefix should be userID:tablename:field 66 | let idx = await this.env.INDEXKV.get(prefix + ':index', { type: 'json' }) 67 | 68 | if (!idx) { 69 | return { 70 | success: false, 71 | error: 'This object doesnt exist in this index' 72 | } 73 | } 74 | 75 | idx = idx.filter(x => x[1] != objectID) 76 | 77 | if (idx.length == 0) { 78 | await this.env.INDEXKV.delete(prefix + ':index') 79 | } else { 80 | await this.env.INDEXKV.put(prefix + ':index', JSON.stringify( idx )) 81 | } 82 | }) 83 | 84 | res.body = { 85 | success: true, 86 | index 87 | } 88 | }) 89 | 90 | return await router.handle(req) 91 | } 92 | } -------------------------------------------------------------------------------- /src/init.js: -------------------------------------------------------------------------------- 1 | // Startup script for when the API was just initiated 2 | // 3 | 4 | import { router } from './router.js' 5 | import { Table } from './lib/table.js' 6 | 7 | 8 | // Basic setup using the built in schema 9 | router.get(router.version + '/__meta/init', async (req, res) => { 10 | // create the accounts "table" 11 | 12 | const accounts = new Table( 13 | 'internal', 14 | 'accounts' 15 | ) 16 | 17 | await accounts.set_spec([ 18 | {'name': 'email', 'type': 'string', 'index': true }, 19 | {'name': 'token', 'type': 'string', 'index': true }, 20 | {'name': 'password_hash', 'type': 'string' }, 21 | {'name': 'customerID', 'type': 'string' }, 22 | {'name': 'options', 'type': 'object' }, 23 | ]) 24 | 25 | const keys = new Table( 26 | 'internal', 27 | 'keys' 28 | ) 29 | 30 | await keys.set_spec([ 31 | {'name': 'userID', 'type': 'string', 'index': true }, 32 | {'name': 'label', 'type': 'string', 'index': true }, 33 | {'name': 'type', 'type': 'string'}, // public or preview 34 | {'name': 'permissions', 'type': 'array' } 35 | ]) 36 | 37 | res.body = { 38 | success: true 39 | } 40 | }) 41 | 42 | router.get(router.version + '/init', async (req, res) => { 43 | res.body = ` 44 | 45 | 46 |
47 |
48 |

49 | Horseman, the headless CMS. 50 |

51 |

52 | Horseman requires an initial account to be setup before you can import a schema. This account will be used to manage the site. You can change this account later. 53 |

54 |
55 | 56 | 57 |
58 |
59 | 60 | 61 |
62 |
63 | 64 | 65 |

66 | JSON-encoded schema for Horseman to setup your account. 67 |

68 |
69 | 70 |
71 |
72 |
73 | 74 | 75 | ` 76 | res.headers['Content-Type'] = 'text/html' 77 | }) -------------------------------------------------------------------------------- /npm-debug.log: -------------------------------------------------------------------------------- 1 | 0 info it worked if it ends with ok 2 | 1 verbose cli [ '/usr/bin/node', '/usr/bin/npm', 'run', 'dev' ] 3 | 2 info using npm@3.5.2 4 | 3 info using node@v8.10.0 5 | 4 verbose run-script [ 'predev', 'dev', 'postdev' ] 6 | 5 info lifecycle miniflare-esbuild-ava@1.0.0~predev: miniflare-esbuild-ava@1.0.0 7 | 6 silly lifecycle miniflare-esbuild-ava@1.0.0~predev: no script for predev, continuing 8 | 7 info lifecycle miniflare-esbuild-ava@1.0.0~dev: miniflare-esbuild-ava@1.0.0 9 | 8 verbose lifecycle miniflare-esbuild-ava@1.0.0~dev: unsafe-perm in lifecycle true 10 | 9 verbose lifecycle miniflare-esbuild-ava@1.0.0~dev: PATH: /usr/share/npm/bin/node-gyp-bin:/root/x-rp/horseman/node_modules/.bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin:/usr/lib/jvm/java-8-openjdk-amd64//bin 11 | 10 verbose lifecycle miniflare-esbuild-ava@1.0.0~dev: CWD: /root/x-rp/horseman 12 | 11 silly lifecycle miniflare-esbuild-ava@1.0.0~dev: Args: [ '-c', 13 | 11 silly lifecycle 'npx miniflare@latest --watch --debug --env .secrets.env' ] 14 | 12 info lifecycle miniflare-esbuild-ava@1.0.0~dev: Failed to exec dev script 15 | 13 verbose stack Error: miniflare-esbuild-ava@1.0.0 dev: `npx miniflare@latest --watch --debug --env .secrets.env` 16 | 13 verbose stack spawn ENOENT 17 | 13 verbose stack at ChildProcess. (/usr/share/npm/lib/utils/spawn.js:17:16) 18 | 13 verbose stack at emitTwo (events.js:126:13) 19 | 13 verbose stack at ChildProcess.emit (events.js:214:7) 20 | 13 verbose stack at maybeClose (internal/child_process.js:925:16) 21 | 13 verbose stack at Process.ChildProcess._handle.onexit (internal/child_process.js:209:5) 22 | 14 verbose pkgid miniflare-esbuild-ava@1.0.0 23 | 15 verbose cwd /root/x-rp/horseman 24 | 16 error Linux 4.15.0-161-generic 25 | 17 error argv "/usr/bin/node" "/usr/bin/npm" "run" "dev" 26 | 18 error node v8.10.0 27 | 19 error npm v3.5.2 28 | 20 error file sh 29 | 21 error code ELIFECYCLE 30 | 22 error errno ENOENT 31 | 23 error syscall spawn 32 | 24 error miniflare-esbuild-ava@1.0.0 dev: `npx miniflare@latest --watch --debug --env .secrets.env` 33 | 24 error spawn ENOENT 34 | 25 error Failed at the miniflare-esbuild-ava@1.0.0 dev script 'npx miniflare@latest --watch --debug --env .secrets.env'. 35 | 25 error Make sure you have the latest version of node.js and npm installed. 36 | 25 error If you do, this is most likely a problem with the miniflare-esbuild-ava package, 37 | 25 error not with npm itself. 38 | 25 error Tell the author that this fails on your system: 39 | 25 error npx miniflare@latest --watch --debug --env .secrets.env 40 | 25 error You can get information on how to open an issue for this project with: 41 | 25 error npm bugs miniflare-esbuild-ava 42 | 25 error Or if that isn't available, you can get their info via: 43 | 25 error npm owner ls miniflare-esbuild-ava 44 | 25 error There is likely additional logging output above. 45 | 26 verbose exit [ 1, true ] 46 | -------------------------------------------------------------------------------- /frontend/src/views/dashboard/base.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://nyc3.digitaloceanspaces.com/cerulean/screenshots/2022/06/Screen%20Shot%202022-06-25%20at%2018.59.43.png) 2 | 3 | # 🐴 Horseman, the headless CMS 4 | 5 | Horseman is a headless CMS thats powered by Cloudflare KV and the Cache API. Using DurableObjects to ensure data indexes are valid and up to date. 6 | 7 | Horseman was created when I wanted to make a news feed for [Wordful](https://wordful.ceru.dev), my multiplayer Wordle game. Looking at other offerings, I wished for something simple, yet quick. After some quick discussions, I realised I could make my own headless CMS by utilising Cloudflare's global KV. This makes Horseman no less than 50ms from anyone around the world. 8 | 9 | You can find a public Horseman instance on my website, here: https://horseman.ceru.dev/ 10 | 11 | API documentation can be found here: https://horseman.stoplight.io/docs/horseman/abe38a2fb9c9d-list-models 12 | 13 | ## ⚡ Features 14 | - World-wide, high speed, infinitely scalable, all thanks to Cloudflare Workers. 15 | - Modern, sleek UI for creating and managing your Objects. 16 | - Caching API built in, smartly clears cache to ensure your data is always up to date world-wide. Also reduces read costs significantly so you dont have to worry about a sudden burst of requests killing your bill. 17 | - Wildcard searching built-in. Alongside other features such as cursors, Horseman can perform wildcard searching on any indexed field. 18 | - For example: `?Field=eq.Hello*` would find any Object with `Hello` in `Field`. Wildcards can go anywhere in the string. 19 | - Relationships to bind two Objects together. Add an Author to that Post without having to type them out every time. 20 | 21 | ## 🔥 Todo 22 | 23 | - Pub/Sub channels for getting up to date information on your Objects. 24 | - An "organisation" system to allow people to be assigned to groups modifying the same group of Objects. 25 | - A plugin system to support third-party modifications to the API. For self-hosted users only, however would allow for people to add custom content-based embeds/RSS feeds. 26 | - Ask for more feedback 27 | - Better documentation 📚 28 | 29 | *Have an idea? Make a discussion post about it and tell us all about your awesome idea!!* 30 | 31 | ## 🔧 Setting up 32 | What you'll need: 33 | - A machine with Wrangler 2 34 | - A paid Workers subscription ($5 a month, used for the password hashing Worker & DurableObjects) 35 | - A setup, working password hashing Worker: (https://github.com/AggressivelyMeows/password-hashing) 36 | - Please read the README to get the passowrd hashing Worker setup correctly. 37 | 38 | #### Step 1 - Config 39 | Open wrangler.toml and edit the lines described by the comments. Make sure to follow them precicely otherwise Horseman will not work. 40 | 41 | #### Step 2 - Publish the Worker 42 | Run `npx wrangler publish` to publish Horseman to your Cloudflare account. Once done, you will neekd to visit `/v1/__meta/init` to start the database. This step is *required* for Horseman to know whats going on. 43 | 44 | ## ✨ Acknowledgements 45 | Horseman was created by Cerulean, all code (except ./src/tsndr_router.js, from https://www.npmjs.com/package/@tsndr/cloudflare-worker-router, read comments for why) is copyright of Connor Vince. Please read the Licence terms before publishing your own Horseman instance. -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | import Router from './tsndr_router.js' 2 | import { Table } from './lib/table.js' 3 | 4 | const router = new Router() 5 | 6 | router.cors() 7 | 8 | router.version = '/v1' 9 | 10 | router.requires_auth = async (req, res, next) => { 11 | const token = req.headers.get('Authorization') || req.query.key 12 | 13 | if (!token) { 14 | res.body = { 15 | success: false, 16 | error: 'No authorization token present', 17 | code: 'AUTH_HEADER_MISSING' 18 | } 19 | res.status = 403 20 | return 21 | } 22 | 23 | const accounts = new Table( 24 | 'internal', 25 | 'accounts' 26 | ) 27 | 28 | let user 29 | if (token.includes('User')) { 30 | user = await accounts.get( 31 | 'token', 32 | token.split(' ')[1] 33 | ) 34 | 35 | user.authorization_type = 'email' 36 | user.read_level = 'preview' 37 | user.permissions = [ 38 | 'read:all', 39 | 'write:all' 40 | ] 41 | } else { 42 | const key = await (new Table( 43 | 'internal', 44 | 'keys' 45 | )).get( 46 | 'id', 47 | token 48 | ) 49 | 50 | if (!key) { 51 | res.body = { 52 | success: false, 53 | error: 'Invalid authorization token', 54 | code: 'AUTH_TOKEN_INVALID' 55 | } 56 | res.status = 403 57 | return 58 | } 59 | 60 | user = await accounts.get( 61 | 'id', 62 | key.userID 63 | ) 64 | 65 | user.authorization_type = 'key' 66 | user.read_level = key.type 67 | user.key = key 68 | user.permissions = key.permissions 69 | } 70 | 71 | req.user = user 72 | 73 | if (user.authorization_type == 'key' && !'modelID' in req.params) { 74 | res.body = { 75 | success: false, 76 | error: 'Invalid use of an API key, must be used with a model', 77 | code: 'AUTH_KEY_INVALID' 78 | } 79 | res.status = 403 80 | return 81 | } 82 | 83 | if ('modelID' in req.params) { 84 | // this is a request targeting one of the models on the API 85 | // check if the user has the right to access this model 86 | const model = req.params.modelID 87 | 88 | if (req.method == 'GET' && !user.permissions.includes('read:all')) { 89 | if (!user.permissions.includes(`read:${model}`)) { 90 | res.body = { 91 | success: false, 92 | error: 'This key does not have access to read this model. Please validate your permissions with the owner of this key.', 93 | code: 'AUTH_MODEL_DENIED' 94 | } 95 | res.status = 403 96 | return 97 | } 98 | } 99 | 100 | if (['POST', 'DELETE'].includes(req.method) && !user.permissions.includes('write:all')) { 101 | if (!user.permissions.includes(`write:${model}`)) { 102 | 103 | res.body = { 104 | success: false, 105 | error: 'This key does not have access to write to this model. Please validate your permissions with the owner of this key.', 106 | code: 'AUTH_MODEL_DENIED' 107 | } 108 | res.status = 403 109 | return 110 | } 111 | } 112 | } 113 | 114 | await next() 115 | } 116 | 117 | router.get('/v1/clr-cache/:id', async (req, res) => { 118 | const accounts = new Table( 119 | 'internal', 120 | 'accounts' 121 | ) 122 | 123 | res.body = { 124 | success: await accounts.cache.delete(req.params.id) 125 | } 126 | }) 127 | 128 | router.get('/v1/cache/:id', async (req, res) => { 129 | const accounts = new Table( 130 | 'internal', 131 | 'accounts' 132 | ) 133 | 134 | res.body = { 135 | cache: await accounts.cache.read(req.params.id) 136 | } 137 | }) 138 | 139 | router.get('/v1/kv/read/:id', async (req, res) => { 140 | res.body = { 141 | obj: await env.CONTENTKV.get(req.params.id, { type: 'json' }), 142 | idx: await env.INDEXKV.get(req.params.id, { type: 'json' }) 143 | } 144 | }) 145 | 146 | 147 | router.get('/v1/kv/write', async (req, res) => { 148 | const ts = new Date().toISOString() 149 | await env.CONTENTKV.put('testing:kv:latency', JSON.stringify({ date: ts, colo_ray: req.headers.get('cf-ray') })) 150 | 151 | res.body = { 152 | ts 153 | } 154 | }) 155 | 156 | export { 157 | router 158 | } -------------------------------------------------------------------------------- /frontend/src/views/dashboard/keys/edit.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | -------------------------------------------------------------------------------- /src/api_v1/models/models.js: -------------------------------------------------------------------------------- 1 | import { router } from '../../router.js' 2 | import { Table } from '../../lib/table.js' 3 | 4 | import slugify from 'slugify' 5 | 6 | router.get(router.version + '/models', router.requires_auth, async (req, res) => { 7 | const tbl = new Table( 8 | req.user.id, 9 | 'models' 10 | ) 11 | 12 | const models = await tbl.list( 13 | '__all_objects_index', 14 | 0, 15 | { 16 | limit: 20, 17 | order: 'newest_first' 18 | } 19 | ) 20 | 21 | res.body = { 22 | success: true, 23 | results: models 24 | } 25 | }) 26 | 27 | router.get(router.version + '/models/:modelID', router.requires_auth, async (req, res) => { 28 | const tbl = new Table( 29 | req.user.id, 30 | 'models' 31 | ) 32 | 33 | let model = await tbl.get( 34 | 'id', 35 | req.params.modelID 36 | ) 37 | 38 | if (!model) { 39 | res.body = { success: false, error: 'Model not found' } 40 | res.status = 404 41 | return 42 | } 43 | 44 | if (model.userID != req.user.id) { 45 | res.body = { success: false, error: 'You do not own this model' } 46 | res.status = 403 47 | return 48 | } 49 | 50 | model.spec = model.spec.filter(field => field.name != '__all_objects_index') 51 | 52 | model.options = model.options || {} 53 | 54 | res.body = { 55 | success: true, 56 | model 57 | } 58 | }) 59 | 60 | router.post(router.version + '/models', router.requires_auth, async (req, res) => { 61 | // create a new model 62 | const tbl = new Table( 63 | req.user.id, 64 | 'models' 65 | ) 66 | 67 | // map the spec to a URL safe form while also doing spec validation 68 | const spec = req.body.spec.map(field => { 69 | //field.name = slugify(field.name, '_').toLowerCase() 70 | return field 71 | }) 72 | 73 | // might be asking why do we force all objects to have an index like below? 74 | // this is because the DB has no native list all objects without there being a unified index for all models and objects 75 | // this field is hidden from the user, cannot be set, cannot be deleted, cannot be read 76 | spec.push({ 77 | name: '__all_objects_index', 78 | type: 'string', 79 | index: true 80 | }) 81 | 82 | req.body.title = req.body.title ||req.body.id 83 | 84 | const model = await tbl.put({ 85 | id: slugify(req.body.title, '-').toLowerCase(), 86 | userID: req.user.id, 87 | spec, 88 | options: req.body.options, 89 | created_at: new Date().toISOString() 90 | }) 91 | 92 | // get table reference 93 | const model_table = new Table( 94 | req.user.id, 95 | slugify(req.body.title, '-').toLowerCase() 96 | ) 97 | 98 | await model_table.set_spec(spec) 99 | 100 | res.body = { 101 | success: true, 102 | model 103 | } 104 | }) 105 | 106 | // UPDATE a model 107 | router.post(router.version + '/models/:modelID', router.requires_auth, async (req, res) => { 108 | // create a new model 109 | const tbl = new Table( 110 | req.user.id, 111 | 'models' 112 | ) 113 | 114 | const existing = await tbl.get( 115 | 'id', 116 | req.params.modelID 117 | ) 118 | 119 | // map the spec to a URL safe form while also doing spec validation 120 | const spec = req.body.spec.map(field => { 121 | //field.name = slugify(field.name, '_').toLowerCase() 122 | return field 123 | }) 124 | 125 | // might be asking why do we force all objects to have an index like below? 126 | // this is because the DB has no native list all objects without there being a unified index for all models and objects 127 | // this field is hidden from the user, cannot be set, cannot be deleted, cannot be read 128 | spec.push({ 129 | name: '__all_objects_index', 130 | type: 'string', 131 | index: true 132 | }) 133 | 134 | await tbl.update( 135 | req.params.modelID, 136 | { 137 | options: req.body.options, 138 | spec, 139 | } 140 | ) 141 | 142 | // get table reference 143 | const model_table = new Table( 144 | req.user.id, 145 | existing.id 146 | ) 147 | 148 | await model_table.set_spec(spec) 149 | 150 | res.body = { 151 | success: true 152 | } 153 | }) 154 | 155 | // DELETE A MODEL 156 | router.delete(router.version + '/models/:modelID', router.requires_auth, async (req, res) => { 157 | // create a new model 158 | const tbl = new Table( 159 | req.user.id, 160 | req.params.modelID 161 | ) 162 | 163 | const idx_keys = ( 164 | await env.INDEXKV.list({ 165 | prefix: tbl.get_kv_prefix() 166 | }) 167 | ).keys.map(k => k.name) 168 | 169 | const object_keys = ( 170 | await env.CONTENTKV.list({ 171 | prefix: tbl.get_kv_prefix() 172 | }) 173 | ).keys.map(k => k.name) 174 | 175 | await Promise.all([ 176 | Promise.all(idx_keys.map(k => env.INDEXKV.delete(k))), 177 | Promise.all(object_keys.map(k => env.CONTENTKV.delete(k))), 178 | ]) 179 | 180 | await new Table(req.user.id, 'models').delete( 181 | req.params.modelID 182 | ) 183 | 184 | res.body = { 185 | success: true 186 | } 187 | }) -------------------------------------------------------------------------------- /frontend/src/views/dashboard/keys/list.vue: -------------------------------------------------------------------------------- 1 | 90 | 91 | -------------------------------------------------------------------------------- /frontend/src/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .mdi { 6 | /* 7 | Yay, love me some Material Design Icon fuckery. 8 | For whatever reason, the icons are not centered in their respective I tags. 9 | */ 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | @apply opacity-75 text-xs; 14 | } 15 | 16 | .mdi-24px.mdi-set, .mdi-24px.mdi::before { 17 | font-size: 1.4em; 18 | } 19 | 20 | .o-inputit__item { 21 | @apply flex flex-row items-center justify-center; 22 | } 23 | 24 | .button { 25 | @apply border-none; 26 | } 27 | 28 | .heading { 29 | @apply text-3xl font-bold text-primary-500 30 | } 31 | 32 | .o-field__message { 33 | @apply text-xs text-gray-400 mt-2 34 | } 35 | 36 | .subheading { 37 | @apply font-medium text-gray-300 text-sm 38 | } 39 | 40 | .o-field__label { 41 | @apply mb-2 font-bold; 42 | } 43 | 44 | .o-input { 45 | @apply border-none rounded-md bg-gray-800 text-sm px-2 py-2 border-gray-700 focus:outline-none focus:border-primary-900 focus:ring-primary-600 focus:ring-1; 46 | } 47 | 48 | .o-input--dark { 49 | @apply bg-gray-900 !important 50 | } 51 | 52 | .o-ctrl-sel { 53 | @apply w-full 54 | } 55 | 56 | .o-sel { 57 | @apply w-full border-none rounded-md bg-gray-800 text-sm px-2 py-2 border-gray-700 focus:outline-none focus:border-primary-900 focus:ring-primary-600 focus:ring-1; 58 | } 59 | 60 | .o-inputit { 61 | @apply rounded-md overflow-hidden border-none 62 | } 63 | 64 | .o-inputit__container { 65 | @apply border-none !important 66 | } 67 | 68 | .o-inputit__item { 69 | @apply bg-primary-600 rounded-md ml-0 mr-2 text-sm font-mono !important; 70 | padding: 8px 4px!important 71 | } 72 | 73 | .o-inputit__input { 74 | @apply text-gray-200 75 | } 76 | 77 | .o-chk__check { 78 | @apply bg-gray-850 text-primary-600 rounded-md border-gray-700 focus:outline-none focus:border-primary-900 focus:ring-primary-600 focus:ring-1 !important 79 | } 80 | 81 | .o-chk__check--checked { 82 | @apply bg-primary-600 !important 83 | } 84 | 85 | .o-chk__label { 86 | @apply ml-2 text-sm 87 | } 88 | 89 | .o-modal__content { 90 | /* Oruga does this as well, until i can figure out how to fix the priority issues with CSS, this will have to be it. */ 91 | @apply rounded-md overflow-hidden bg-gray-800 text-gray-200 max-w-lg p-4 !important 92 | } 93 | 94 | .o-acp__menu { 95 | @apply rounded-md overflow-hidden bg-gray-800 text-gray-200 !important 96 | } 97 | 98 | .o-acp__item { 99 | @apply p-2 hover:bg-gray-850 hover:text-primary-500 100 | } 101 | 102 | .notif { 103 | @apply p-2 px-3 rounded-md mb-3 mr-3 cursor-pointer 104 | } 105 | 106 | .notif.success { 107 | @apply text-gray-200 bg-green-600 108 | } 109 | 110 | .notif.warning { 111 | @apply text-gray-200 bg-yellow-600 112 | } 113 | 114 | .notif.error { 115 | @apply text-gray-200 bg-red-600 116 | } 117 | 118 | .notif.info { 119 | @apply text-gray-200 bg-blue-600 120 | } 121 | 122 | .notification-content { 123 | @apply text-sm 124 | } 125 | 126 | 127 | 128 | .ql-syntax { 129 | @apply bg-gray-900 p-2 !important; 130 | } 131 | 132 | .ql-html-textArea { 133 | @apply bg-transparent !important; 134 | } 135 | 136 | .ql-html-textArea > .ql-editor { 137 | @apply p-0 pt-2 !important 138 | } 139 | 140 | .ql-html-popupContainer { 141 | @apply bg-gray-800 text-gray-200 !important 142 | } 143 | 144 | .ql-html-buttonCancel { 145 | @apply bg-red-600 text-white font-medium p-2 rounded-md text-xs !important; 146 | } 147 | 148 | .ql-html-buttonOk { 149 | @apply bg-green-600 text-white font-medium p-2 rounded-md text-xs !important; 150 | } 151 | 152 | .ql-snow .ql-picker.ql-font .ql-picker-item[data-value="serif"]::before, .ql-font-serif { 153 | font-family: 'Merriweather', serif!important; 154 | } 155 | 156 | .ql-snow .ql-picker.ql-font .ql-picker-item[data-value="monospace"]::before, .ql-font-monospace { 157 | font-family: 'Source Code Pro', monospace!important; 158 | } 159 | 160 | .ql-container { 161 | height:unset!important; 162 | @apply bg-gray-800 163 | } 164 | 165 | .ql-toolbar.ql-snow { 166 | /* We need to do important because CSS makes the editor CSS higher prio than our edits */ 167 | @apply border-none rounded-t-md mx-0 px-0 !important 168 | } 169 | 170 | .ql-container.ql-snow { 171 | @apply border-none rounded-md !important 172 | } 173 | 174 | .ql-container { 175 | font-family: "Inter var", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"!important; 176 | } 177 | 178 | .ql-formats { 179 | @apply mr-2 !important 180 | } 181 | 182 | .ql-formats > * { 183 | @apply text-gray-200 mr-1 !important 184 | } 185 | 186 | .ql-picker-label { 187 | @apply rounded-md bg-gray-800 border-none !important 188 | } 189 | 190 | .ql-active { 191 | @apply rounded-md bg-gray-900 text-gray-200 !important 192 | } 193 | 194 | .ql-snow.ql-toolbar button { 195 | @apply bg-gray-800 rounded-md flex items-center justify-center !important 196 | } 197 | 198 | .ql-snow.ql-toolbar button.ql-active, .ql-snow .ql-toolbar button.ql-active, .ql-snow.ql-toolbar .ql-picker-label.ql-active, .ql-snow .ql-toolbar .ql-picker-label.ql-active, .ql-snow.ql-toolbar .ql-picker-item.ql-selected, .ql-snow .ql-toolbar .ql-picker-item.ql-selected { 199 | @apply text-primary-500 !important 200 | } 201 | 202 | .ql-snow.ql-toolbar button:hover, .ql-snow .ql-toolbar button:hover, .ql-snow.ql-toolbar button:focus, .ql-snow .ql-toolbar button:focus, .ql-snow.ql-toolbar .ql-picker-label:hover, .ql-snow .ql-toolbar .ql-picker-label:hover, .ql-snow.ql-toolbar .ql-picker-item:hover, .ql-snow .ql-toolbar { 203 | @apply bg-gray-900 rounded-md !important 204 | } 205 | 206 | .ql-snow.ql-toolbar button.ql-active, .ql-snow .ql-toolbar button.ql-active, .ql-snow.ql-toolbar .ql-picker-label.ql-active, .ql-snow .ql-toolbar .ql-picker-label.ql-active, .ql-snow.ql-toolbar .ql-picker-item.ql-selected, .ql-snow .ql-toolbar .ql-picker-item.ql-selected { 207 | @apply bg-primary-500/10 !important 208 | } 209 | 210 | .ql-editor { 211 | @apply prose prose-invert prose-headings:font-bold prose-headings:mb-2 max-w-full w-full 212 | } 213 | 214 | .ql-snow .ql-picker-options { 215 | @apply bg-gray-800 !important 216 | } 217 | 218 | .ql-editor.ql-blank::before { 219 | @apply text-gray-600 font-medium !important 220 | } 221 | 222 | .ql-picker-options { 223 | @apply rounded-md overflow-hidden border-none !important 224 | } 225 | 226 | .ql-picker-item:hover { 227 | @apply bg-gray-900 !important 228 | } 229 | 230 | .ql-picker-options .ql-selected:not([data-value]) { 231 | @apply bg-gray-900 text-primary-500 !important 232 | } 233 | 234 | .ql-picker-item:not([data-value]) { 235 | @apply bg-gray-800 !important 236 | } 237 | 238 | .ql-snow .ql-stroke { 239 | @apply stroke-gray-200 !important 240 | } -------------------------------------------------------------------------------- /src/api_v1/models/presets.js: -------------------------------------------------------------------------------- 1 | import { router } from '../../router.js' 2 | import { Table } from '../../lib/table.js' 3 | 4 | // { 5 | // "id": "horseman-news", 6 | // "spec": [ 7 | // { 8 | // "name": "Title", 9 | // "type": "string", 10 | // "args": {}, 11 | // "index": true 12 | // }, 13 | // { 14 | // "name": "Content", 15 | // "type": "html", 16 | // "args": {} 17 | // }, 18 | // { 19 | // "name": "Tags", 20 | // "type": "tag", 21 | // "args": {}, 22 | // "index": true 23 | // }, 24 | // { 25 | // "name": "__all_objects_index", 26 | // "type": "string", 27 | // "index": true 28 | // } 29 | // ], 30 | // "options": { 31 | // "title_field": "Title" 32 | // }, 33 | // } 34 | 35 | const generate_model = (name, spec, options) => { 36 | return { 37 | id: name, 38 | spec: spec, 39 | options: options 40 | } 41 | } 42 | 43 | router.get(router.version + '/presets', async (req, res) => { 44 | const presets = [ 45 | { 46 | name: 'Blog', 47 | desc: 'A preset that gets you setup for writing blogs', 48 | models: [ 49 | generate_model( 50 | 'posts', 51 | [ 52 | { 53 | name: 'Title', 54 | type: 'string', 55 | args: {}, 56 | index: true, 57 | help: 'The title of the blog post.' 58 | }, 59 | { 60 | name: 'Author', 61 | type: 'relationship', 62 | args: { 63 | model: 'authors' 64 | }, 65 | index: true, 66 | help: 'This is your author profile. If you havent made one yet, swap over to the "Authors" tab and create one. Make sure to publish it and then come back.' 67 | }, 68 | { 69 | name: 'Content', 70 | type: 'html', 71 | args: {} 72 | }, 73 | { 74 | name: 'Tags', 75 | type: 'tag', 76 | args: {}, 77 | index: true 78 | } 79 | ], 80 | { 81 | title_field: 'Title', 82 | preset: true 83 | } 84 | ), 85 | generate_model( 86 | 'authors', 87 | [ 88 | { 89 | name: 'Name', 90 | type: 'string', 91 | args: {}, 92 | index: true 93 | }, 94 | { 95 | name: 'Biography', 96 | type: 'html', 97 | args: {} 98 | }, 99 | { 100 | name: 'Image', 101 | type: 'string', 102 | args: {}, 103 | help: 'A URL of your avatar goes here.' 104 | } 105 | ], 106 | { 107 | title_field: 'Name', 108 | preset: true 109 | } 110 | ) 111 | ] 112 | }, 113 | { 114 | name: 'Portfolio', 115 | desc: 'A preset that gets you setup for writing portfolios', 116 | models: [ 117 | generate_model( 118 | 'projects', 119 | [ 120 | { 121 | name: 'Title', 122 | type: 'string', 123 | args: {}, 124 | index: true, 125 | help: 'The title of the project.' 126 | }, 127 | { 128 | name: 'Tags', 129 | type: 'tag', 130 | args: {}, 131 | index: true, 132 | help: 'Any tags you want to associate to this project, for example "Vue.JS", "Tailwind", "Python", "PHP".' 133 | }, 134 | { 135 | name: 'Image', 136 | type: 'string', 137 | args: {}, 138 | index: false, 139 | help: 'The image that will be used to represent this project on your home page or portfolio page.' 140 | }, 141 | { 142 | name: 'Content', 143 | type: 'html', 144 | args: {}, 145 | help: 'Give us a description of your project, what you did, how you did it, what are you proud of, etc.' 146 | } 147 | ], 148 | { 149 | title_field: 'Title', 150 | preset: true 151 | } 152 | ), 153 | generate_model( 154 | 'authors', 155 | [ 156 | { 157 | name: 'Name', 158 | type: 'string', 159 | args: {}, 160 | index: true 161 | }, 162 | { 163 | name: 'Biography', 164 | type: 'html', 165 | args: {} 166 | }, 167 | { 168 | name: 'Image', 169 | type: 'string', 170 | args: {} 171 | } 172 | ], 173 | { 174 | title_field: 'Name', 175 | preset: true 176 | } 177 | ) 178 | ] 179 | } 180 | ] 181 | 182 | res.body = { 183 | success: true, 184 | presets 185 | } 186 | }) -------------------------------------------------------------------------------- /frontend/src/views/dashboard/objects/list.vue: -------------------------------------------------------------------------------- 1 | 102 | 103 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ----------------------------------------------------------------------------- 2 | 3 | Business Source License 1.1 4 | 5 | License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. 6 | "Business Source License" is a trademark of MariaDB Corporation Ab. 7 | 8 | ----------------------------------------------------------------------------- 9 | 10 | Parameters 11 | 12 | Licensor: Connor Vince 13 | 14 | Licensed Work: Horseman, the headless CMS 15 | The Licensed Work is (c) 2022 Connor Vince. 16 | 17 | Additional Use Grant: You may make use of the Licensed Work, provided you 18 | do not use it in any of the following ways: 19 | 20 | 21 | * Sell hosted Horseman services as a "SaaS" Product 22 | 23 | (1) Operate or sell access to Horseman APIs, data, 24 | or authorization of the Licensed Work as a 25 | for-profit service, regardless of whether the use of 26 | these components is sold alone or is bundled with other 27 | services. Note that this does not apply to the use of 28 | Horseman behind the scenes to operate a service not 29 | related to Horseman's API. 30 | 31 | * Create Non-Open-Source Commercial Derviative Works 32 | 33 | (2) Link or directly include the Licensed Work in a 34 | commercial or for-profit application or other product 35 | not distributed under an Open Source Initiative (OSI) 36 | compliant license. See: https://opensource.org/licenses 37 | 38 | (3) Remove the name, logo, copyright, or other branding 39 | material from the Licensed Work to create a "rebranded" 40 | or "white labeled" version to distribute as part of 41 | any commercial or for-profit product or service. 42 | 43 | * Certain Government Uses 44 | 45 | (4) Use or deploy the Licensed Work in a government 46 | setting in support of any active government function 47 | or operation with the exception of the following: 48 | physical or mental health care, family and social 49 | services, social welfare, senior care, child care, and 50 | the care of persons with disabilities. 51 | 52 | Change Date: 2024-01-01 53 | 54 | Change License: Apache License version 2.0 as published by the Apache 55 | Software Foundation 56 | https://www.apache.org/licenses/ 57 | 58 | Alternative Licensing 59 | 60 | If you would like to use the Licensed Work in any way that conflicts with 61 | the stipulations of the Additional Use Grant, contact Connor Vince. to 62 | obtain an alternative commercial license. 63 | 64 | Visit us on the web at: https://horseman.ceru.dev 65 | 66 | Notice 67 | 68 | The Business Source License (this document, or the "License") is not an Open 69 | Source license. However, the Licensed Work will eventually be made available 70 | under an Open Source License, as stated in this License. 71 | 72 | For more information on the use of the Business Source License generally, 73 | please visit the Adopting and Developing Business Source License FAQ at 74 | https://mariadb.com/bsl-faq-adopting. 75 | 76 | ----------------------------------------------------------------------------- 77 | 78 | Business Source License 1.1 79 | 80 | Terms 81 | 82 | The Licensor hereby grants you the right to copy, modify, create derivative 83 | works, redistribute, and make non-production use of the Licensed Work. The 84 | Licensor may make an Additional Use Grant, above, permitting limited 85 | production use. 86 | 87 | Effective on the Change Date, or the fourth anniversary of the first publicly 88 | available distribution of a specific version of the Licensed Work under this 89 | License, whichever comes first, the Licensor hereby grants you rights under 90 | the terms of the Change License, and the rights granted in the paragraph 91 | above terminate. 92 | 93 | If your use of the Licensed Work does not comply with the requirements 94 | currently in effect as described in this License, you must purchase a 95 | commercial license from the Licensor, its affiliated entities, or authorized 96 | resellers, or you must refrain from using the Licensed Work. 97 | 98 | All copies of the original and modified Licensed Work, and derivative works 99 | of the Licensed Work, are subject to this License. This License applies 100 | separately for each version of the Licensed Work and the Change Date may vary 101 | for each version of the Licensed Work released by Licensor. 102 | 103 | You must conspicuously display this License on each original or modified copy 104 | of the Licensed Work. If you receive the Licensed Work in original or 105 | modified form from a third party, the terms and conditions set forth in this 106 | License apply to your use of that work. 107 | 108 | Any use of the Licensed Work in violation of this License will automatically 109 | terminate your rights under this License for the current and all other 110 | versions of the Licensed Work. 111 | 112 | This License does not grant you any right in any trademark or logo of 113 | Licensor or its affiliates (provided that you may use a trademark or logo of 114 | Licensor as expressly required by this License). 115 | 116 | TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON 117 | AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, 118 | EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF 119 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND 120 | TITLE. 121 | 122 | ----------------------------------------------------------------------------- 123 | 124 | MariaDB hereby grants you permission to use this License’s text to license 125 | your works, and to refer to it using the trademark "Business Source License", 126 | as long as you comply with the Covenants of Licensor below. 127 | 128 | Covenants of Licensor 129 | 130 | In consideration of the right to use this License’s text and the "Business 131 | Source License" name and trademark, Licensor covenants to MariaDB, and to all 132 | other recipients of the licensed work to be provided by Licensor: 133 | 134 | 1. To specify as the Change License the GPL Version 2.0 or any later version, 135 | or a license that is compatible with GPL Version 2.0 or a later version, 136 | where "compatible" means that software provided under the Change License can 137 | be included in a program with software provided under GPL Version 2.0 or a 138 | later version. Licensor may specify additional Change Licenses without 139 | limitation. 140 | 141 | 2. To either: (a) specify an additional grant of rights to use that does not 142 | impose any additional restriction on the right granted in this License, as 143 | the Additional Use Grant; or (b) insert the text "None". 144 | 145 | 3. To specify a Change Date. 146 | 147 | 4. Not to modify this License in any other way. -------------------------------------------------------------------------------- /src/api_v1/models/content.js: -------------------------------------------------------------------------------- 1 | import { router } from '../../router.js' 2 | import { Table } from '../../lib/table.js' 3 | 4 | import slugify from 'slugify' 5 | import mask from 'json-mask' 6 | 7 | // LIST OBJECT 8 | router.get(router.version + '/models/:modelID/objects', router.requires_auth, async (req, res) => { 9 | const tbl = new Table( 10 | req.user.id, 11 | req.params.modelID 12 | ) 13 | 14 | let index = req.query.index || '__all_objects_index' 15 | let query = req.query.q || 0 16 | let mode = 'eq' 17 | 18 | const spec = await tbl.get_spec() 19 | 20 | const target = Object.keys(req.query).find(q => spec.map(x => x.name.toLowerCase()).includes(q.toLowerCase())) 21 | 22 | if (target) { 23 | query = req.query[target] 24 | 25 | // parse query 26 | if (query.split('.').length == 2) { 27 | mode = query.split('.')[0] 28 | query = query.split('.')[1] 29 | } else { 30 | res.body = { 31 | success: false, 32 | error: 'Query filters require a prefix, for example: "eq.value", "gt.1", "neq.bad value"' 33 | } 34 | res.status = 400 35 | return 36 | } 37 | 38 | index = target 39 | } 40 | 41 | let models = await tbl.list( 42 | index, 43 | query, 44 | { 45 | mode, 46 | limit: 20, 47 | order: 'newest_first', 48 | read_level: req.user.read_level, 49 | } 50 | ) 51 | 52 | models = models.map(x => { 53 | let new_obj = Object.assign({}, x) 54 | 55 | Object.keys(x).map(field_key => { 56 | const field = spec.find(field => field.name.toLowerCase() == field_key.toLowerCase()) 57 | 58 | if (field) { 59 | if (field.type == 'html') { 60 | new_obj[field_key] = new_obj[field_key].replaceAll('


', '
') 61 | } else { 62 | new_obj[field_key] = new_obj[field_key] 63 | } 64 | } else { 65 | new_obj[field_key] = new_obj[field_key] 66 | } 67 | }) 68 | 69 | return new_obj 70 | }) 71 | 72 | if ('expand' in req.query) { 73 | const expand = req.query.expand.split(',') 74 | 75 | for (let target of expand) { 76 | // expand each objects internal fields if the field type is "relationship" 77 | const is_valid = spec.filter(x => x.type=='relationship') 78 | .map(x => x.name) 79 | .includes(target) 80 | 81 | if (!is_valid) { 82 | res.body = { 83 | success: false, 84 | error: 'Invalid expand target: ' + target, 85 | extra: `Must be one of ${spec.filter(x=>x.type=='relationship').map(x => x.name).join(', ')}`, 86 | code: 'INVALID_EXPAND_TARGET' 87 | } 88 | res.status = 400 89 | return 90 | } 91 | 92 | const expand_table = new Table( 93 | req.user.id, 94 | spec.find(x => x.name == target).args.model 95 | ) 96 | 97 | models = await Promise.all(models.map(async found_item => { 98 | const object = await expand_table.get( 99 | 'id', 100 | found_item[target] 101 | ) 102 | 103 | if (!object.id) { 104 | found_item[target] = null 105 | return found_item 106 | } 107 | 108 | if (req.user.read_level == 'preview' || object?.metadata?.published) { 109 | delete object.__all_objects_index 110 | found_item[target] = object 111 | return found_item 112 | } 113 | 114 | found_item[target] = { 115 | error: 'Object is not published, cannot be expanded. This API key does not have the required read level.', 116 | } 117 | 118 | return found_item 119 | })) 120 | } 121 | } 122 | 123 | 124 | res.body = { 125 | success: true, 126 | results: models.map(x => { 127 | delete x.__all_objects_index 128 | return req.query.fields ? mask(x, req.query.fields) : x 129 | }), 130 | model: await (new Table(req.user.id, 'models')).get( 131 | 'id', 132 | req.params.modelID 133 | ) 134 | } 135 | }) 136 | 137 | // GET OBJECT 138 | router.get(router.version + '/models/:modelID/objects/:objectID', router.requires_auth, async (req, res) => { 139 | const tbl = new Table( 140 | req.user.id, 141 | req.params.modelID 142 | ) 143 | 144 | const object = await tbl.get( 145 | 'id', 146 | req.params.objectID 147 | ) 148 | 149 | delete object.__all_objects_index 150 | 151 | res.body = { 152 | success: true, 153 | object 154 | } 155 | }) 156 | 157 | // CREATE A NEW OBJECT 158 | router.post(router.version + '/models/:modelID/objects', router.requires_auth, async (req, res) => { 159 | const tbl = new Table( 160 | req.user.id, 161 | req.params.modelID 162 | ) 163 | 164 | req.body.__all_objects_index = 0 165 | 166 | req.body.metadata = { 167 | userID: req.user.id, 168 | published: false, 169 | created_at: new Date().toISOString() 170 | } 171 | 172 | const model = await tbl.put(req.body) 173 | 174 | res.body = { 175 | success: true, 176 | model 177 | } 178 | }) 179 | 180 | // UPDATE AN OBJECT 181 | router.post(router.version + '/models/:modelID/objects/:objectID', router.requires_auth, async (req, res) => { 182 | // create a new model 183 | const tbl = new Table( 184 | req.user.id, 185 | req.params.modelID 186 | ) 187 | 188 | const spec = await tbl.get_spec() 189 | 190 | const update = {} 191 | 192 | spec.map(x => { 193 | // prevents the user from updating keys and values we need for record keeping like metadata and id. 194 | if (req.body[x.name]) { 195 | update[x.name] = req.body[x.name] 196 | } 197 | }) 198 | 199 | await tbl.update( 200 | req.params.objectID, 201 | update 202 | ) 203 | 204 | res.body = { 205 | success: true 206 | } 207 | }) 208 | 209 | // UPDATE AN OBJECTS STATE 210 | router.post(router.version + '/models/:modelID/objects/:objectID/state', router.requires_auth, async (req, res) => { 211 | const tbl = new Table( 212 | req.user.id, 213 | req.params.modelID 214 | ) 215 | 216 | const object = await tbl.get( 217 | 'id', 218 | req.params.objectID 219 | ) 220 | 221 | if (req.body.published) { 222 | object.metadata.published = true 223 | object.metadata.published_at = new Date().toISOString() 224 | } else { 225 | object.metadata.published = false 226 | object.metadata.published_at = null 227 | } 228 | 229 | await tbl.update( 230 | req.params.objectID, 231 | { 232 | metadata: object.metadata 233 | } 234 | ) 235 | 236 | res.body = { 237 | success: true, 238 | published: object.metadata.published 239 | } 240 | }) 241 | 242 | // DELETE AN OBJECT 243 | router.delete(router.version + '/models/:modelID/objects/:objectID', router.requires_auth, async (req, res) => { 244 | // create a new model 245 | const tbl = new Table( 246 | req.user.id, 247 | req.params.modelID 248 | ) 249 | 250 | await tbl.delete( 251 | req.params.objectID 252 | ) 253 | 254 | res.body = { 255 | success: true 256 | } 257 | }) 258 | 259 | // CLEAR MODEL CACHE 260 | router.delete(router.version + '/models/:modelID/cache', router.requires_auth, async (req, res) => { 261 | const tbl = new Table( 262 | req.user.id, 263 | req.params.modelID 264 | ) 265 | 266 | await tbl.get_spec() 267 | 268 | // list all of this models indexes 269 | const indexes = tbl.spec.map(x => x.index ? x.name : null ).filter(x=>x) 270 | 271 | await Promise.all(indexes.map(async x => { 272 | return tbl.cache.delete(`${tbl.get_kv_prefix()}:${x}:index`) 273 | })) 274 | 275 | const keys = await tbl.list( 276 | '__all_objects_index', 277 | 0, 278 | { 279 | mode: 'eq', 280 | limit: 1000, 281 | resolve: false 282 | } 283 | ) 284 | 285 | await Promise.all(keys.map(async x => { 286 | console.log(`${tbl.get_kv_prefix()}:${x}`) 287 | return tbl.cache.delete(`${tbl.get_kv_prefix()}:${x}`) 288 | })) 289 | 290 | res.body = { 291 | success: true, 292 | } 293 | }) -------------------------------------------------------------------------------- /src/api_v1/auth/accounts.js: -------------------------------------------------------------------------------- 1 | import { router } from '../../router.js' 2 | import { Table } from '../../lib/table.js' 3 | 4 | router.get(router.version + '/auth/@me', router.requires_auth, async (req, res) => { 5 | 6 | 7 | req.user.options = req.user.options || {} // polyfill, babeyy 8 | 9 | res.body = { 10 | success: true, 11 | user: req.user 12 | } 13 | }) 14 | 15 | router.post(router.version + '/auth/password-fix', router.requires_auth, async (req, res) => { 16 | const hash_resp = await env.PASSWORD_HASHING.fetch('https://hash.com/v1/hash', { 17 | method: 'POST', 18 | headers: { 'Authorization': env.PASSWORD_HASHER_KEY, 'content-type': 'application/json' }, 19 | body: JSON.stringify({ 20 | password: req.body.password 21 | }) 22 | }).then(r => r.json()) 23 | 24 | if (!hash_resp.success) { 25 | res.body = { 26 | success: false, 27 | error: `We're sorry, there was an issue with the password hashing service. Please try again later.` 28 | } 29 | res.status = 500 30 | return 31 | } 32 | 33 | const hashed_password = hash_resp.hash 34 | 35 | const acc = new Table( 36 | 'internal', 37 | 'accounts' 38 | ) 39 | 40 | await acc.update( 41 | req.user.id, 42 | { 43 | password_hash: hashed_password 44 | } 45 | ) 46 | 47 | res.body = { 48 | success: true 49 | } 50 | }) 51 | 52 | router.post(router.version + '/auth/signup', async (req, res) => { 53 | if (!env.ALLOW_SIGNUPS) { 54 | res.body = { 55 | success: false, 56 | error: 'Signups are not allowed right now, please try again later.', 57 | code: 'SIGNUPS_NOT_ALLOWED' 58 | } 59 | res.status = 403 60 | return 61 | } 62 | 63 | const acc = new Table( 64 | 'internal', 65 | 'accounts' 66 | ) 67 | 68 | const data = req.body 69 | 70 | if (!data.password) { 71 | res.body = { 72 | success: false, 73 | error: 'No password provided' 74 | } 75 | res.status = 400 76 | return 77 | } 78 | 79 | if (await acc.get('email', data.email)) { 80 | res.body = { 81 | success: false, 82 | error: 'Email already in use' 83 | } 84 | res.status = 400 85 | return 86 | } 87 | 88 | const hash_resp = await env.PASSWORD_HASHING.fetch('https://hash.com/v1/hash', { 89 | method: 'POST', 90 | headers: { 'Authorization': env.PASSWORD_HASHER_KEY, 'content-type': 'application/json' }, 91 | body: JSON.stringify({ 92 | password: req.body.password 93 | }) 94 | }).then(r => r.json()) 95 | 96 | if (!hash_resp.success) { 97 | res.body = { 98 | success: false, 99 | error: `We're sorry, there was an issue with the password hashing service. Please try again later.` 100 | } 101 | res.status = 500 102 | return 103 | } 104 | 105 | const hashed_password = hash_resp.hash 106 | 107 | 108 | 109 | const account = await acc.put({ 110 | 'email': data.email, 111 | 'token': acc.generate_id(20), 112 | 'password_hash': hashed_password 113 | }) 114 | 115 | const keys = new Table( 116 | 'internal', 117 | 'keys' 118 | ) 119 | 120 | // we can use the ID of the key instead of a token since the tok and ID gen are the same generator. 121 | await keys.put({ 122 | userID: account.id, 123 | type: 'public', // public keys are used for accessing Objects that are published 124 | // a key with the type of `preview` is used for accessing Objects that are marked as "draft" 125 | permissions: [ 'read:all' ], 126 | }) 127 | 128 | res.body = { 129 | success: true, 130 | account 131 | } 132 | }) 133 | 134 | router.post(router.version + '/auth/login', async (req, res) => { 135 | const data = req.body 136 | 137 | const acc = new Table( 138 | 'internal', 139 | 'accounts' 140 | ) 141 | 142 | const existing = await acc.get( 143 | 'email', 144 | data.email 145 | ) 146 | 147 | if (!existing) { 148 | res.body = { 149 | success: false, 150 | error: 'No account found with that email address.', 151 | code: 'NO_ACCOUNT' 152 | } 153 | res.status = 403 154 | return 155 | } 156 | 157 | const resp = await env.PASSWORD_HASHING.fetch('https://hash.com/v1/compare', { 158 | method: 'POST', 159 | headers: { 'Authorization': env.PASSWORD_HASHER_KEY, 'content-type': 'application/json' }, 160 | body: JSON.stringify({ 161 | password: req.body.password, 162 | hash: existing.password_hash 163 | }) 164 | }).then(resp => resp.json()) 165 | 166 | console.log(resp) 167 | 168 | if (!resp.is_same) { 169 | res.body = { 170 | success: false, 171 | error: 'Email/password incorrect', 172 | code: 'INCORRECT_AUTH' 173 | } 174 | res.status = 403 175 | return 176 | } 177 | 178 | res.body = { 179 | success: true, 180 | user: existing 181 | } 182 | }) 183 | 184 | router.get(router.version + '/auth/keys', router.requires_auth, async (req, res) => { 185 | const keys = await new Table('internal', 'keys').list( 186 | 'userID', 187 | req.user.id, 188 | { 189 | limit: 100, 190 | order: 'newest_first' 191 | } 192 | ) 193 | 194 | res.body = { 195 | success: true, 196 | results: keys 197 | } 198 | }) 199 | 200 | router.get(router.version + '/auth/keys/:keyID', router.requires_auth, async (req, res) => { 201 | const tbl = new Table('internal', 'keys') 202 | 203 | const key = await tbl.get( 204 | 'id', 205 | req.params.keyID 206 | ) 207 | 208 | if (!key) { 209 | res.body = { 210 | success: false, 211 | error: 'Key not found', 212 | code: 'KEY_NOT_FOUND' 213 | } 214 | res.status = 404 215 | return 216 | } 217 | 218 | if (key.userID !== req.user.id) { 219 | res.body = { 220 | success: false, 221 | error: 'You do not have permission to access this key', 222 | code: 'NO_PERMISSION' 223 | } 224 | res.status = 403 225 | return 226 | } 227 | 228 | res.body = { 229 | success: true, 230 | key 231 | } 232 | }) 233 | 234 | // POST 235 | router.post(router.version + '/auth/keys', router.requires_auth, async (req, res) => { 236 | const keys = new Table( 237 | 'internal', 238 | 'keys' 239 | ) 240 | 241 | const key = await keys.put({ 242 | userID: req.user.id, 243 | type: req.body.type, // public keys are used for accessing Objects that are published 244 | label: req.body.label.substring(0, 255), 245 | // a key with the type of `preview` is used for accessing Objects that are marked as "draft" 246 | permissions: req.body.permissions, 247 | }) 248 | 249 | res.body = { 250 | success: true, 251 | key 252 | } 253 | }) 254 | 255 | // Update a key 256 | router.post(router.version + '/auth/keys/:keyID', router.requires_auth, async (req, res) => { 257 | const tbl = new Table( 258 | 'internal', 259 | 'keys' 260 | ) 261 | 262 | const key = await tbl.get( 263 | 'id', 264 | req.params.keyID 265 | ) 266 | 267 | if (!key) { 268 | res.body = { 269 | success: false, 270 | error: 'Key not found', 271 | code: 'KEY_NOT_FOUND' 272 | } 273 | res.status = 404 274 | return 275 | } 276 | 277 | if (key.userID !== req.user.id) { 278 | res.body = { 279 | success: false, 280 | error: 'You do not have permission to access this key', 281 | code: 'NO_PERMISSION' 282 | } 283 | res.status = 403 284 | return 285 | } 286 | 287 | const update = { 288 | type: req.body.type, 289 | label: req.body.label.substring(0, 255), 290 | permissions: req.body.permissions, 291 | } 292 | 293 | await tbl.update( 294 | req.params.keyID, 295 | update 296 | ) 297 | 298 | res.body = { 299 | success: true 300 | } 301 | }) 302 | 303 | 304 | router.delete(router.version + '/auth/keys/:keyID', router.requires_auth, async (req, res) => { 305 | const tbl = new Table('internal', 'keys') 306 | 307 | const key = await tbl.get( 308 | 'id', 309 | req.params.keyID 310 | ) 311 | 312 | if (!key) { 313 | res.body = { 314 | success: false, 315 | error: 'Key not found', 316 | code: 'KEY_NOT_FOUND' 317 | } 318 | res.status = 404 319 | return 320 | } 321 | 322 | if (key.userID !== req.user.id) { 323 | res.body = { 324 | success: false, 325 | error: 'You do not have permission to access this key', 326 | code: 'NO_PERMISSION' 327 | } 328 | res.status = 403 329 | return 330 | } 331 | 332 | await tbl.delete( 333 | key.id 334 | ) 335 | 336 | res.body = { 337 | success: true, 338 | key 339 | } 340 | }) -------------------------------------------------------------------------------- /frontend/src/views/dashboard/objects/edit.vue: -------------------------------------------------------------------------------- 1 |