├── tsconfig.dev.json ├── .prettierignore ├── .firebaserc ├── .prettierrc.json ├── .github ├── pull_request_template.md └── workflows │ ├── pull_request.yml │ └── main.yml ├── firebase.json ├── src ├── db │ ├── index.ts │ ├── SavedCuesRepo.ts │ ├── CounterRepo.ts │ └── DbGateway.ts ├── types.ts ├── middleware │ ├── errorMiddleware.ts │ ├── CORSMiddleware.ts │ └── clientAuthMiddleware.ts ├── config.ts ├── index.ts └── routes │ └── index.ts ├── tsconfig.json ├── CHANGELOG.md ├── .eslintrc.js ├── README.md ├── .gitignore └── package.json /tsconfig.dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [".eslintrc.js"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | ./build 3 | ./coverage 4 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "cuegenerator" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | fix|feat|docs|test|build|refactor: subject 2 | 3 | [optional body] 4 | 5 | BREAKING CHANGE: ? -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "source": ".", 4 | "predeploy": ["npm --prefix \"$RESOURCE_DIR\" run build"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | import CounterRepo from './CounterRepo'; 2 | import SavedCuesRepo from './SavedCuesRepo'; 3 | 4 | export const counterRepo = new CounterRepo(); 5 | export const savedCuesRepo = new SavedCuesRepo(); 6 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { firestore } from 'firebase-admin'; 2 | 3 | export type Counter = { value: number }; 4 | export type Cue = { performer: string; title: string; fileName: string; cue: string; createdAt: firestore.Timestamp }; 5 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: Test build on PR 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - run: npm ci && npm test 12 | -------------------------------------------------------------------------------- /src/middleware/errorMiddleware.ts: -------------------------------------------------------------------------------- 1 | import * as functions from 'firebase-functions'; 2 | import { Request, Response, NextFunction } from 'express'; 3 | 4 | export default function errorMiddleware(err: Error, req: Request, res: Response, next: NextFunction) { 5 | functions.logger.error(err.message, { url: req.originalUrl, reqMethod: req.method, reqBody: req.body, err }); 6 | res.status(500).json({ error: err.message }); 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "noImplicitReturns": true, 5 | "noFallthroughCasesInSwitch": true, 6 | "noUnusedLocals": false, 7 | "outDir": "lib", 8 | "sourceMap": true, 9 | "strict": true, 10 | "target": "ES2022", 11 | "lib": ["ES2023"], 12 | "esModuleInterop": true 13 | }, 14 | "compileOnSave": true, 15 | "include": ["src"] 16 | } 17 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { defineSecret } from 'firebase-functions/params'; 2 | 3 | export const API_SECRET = defineSecret('API_SECRET'); 4 | 5 | export const allowedOrigins = [ 6 | 'http://localhost:3000', 7 | 'http://localhost:5173', 8 | 'https://cuegenerator.firebaseapp.com', 9 | 'https://cuegenerator.web.app', 10 | 'https://cuegenerator-v3.firebaseapp.com', 11 | 'https://cuegenerator-v3.web.app', 12 | 'https://cuegenerator.net', 13 | ]; 14 | -------------------------------------------------------------------------------- /src/db/SavedCuesRepo.ts: -------------------------------------------------------------------------------- 1 | import { firestore } from 'firebase-admin'; 2 | import db from './DbGateway'; 3 | 4 | const FieldValue = firestore.FieldValue; 5 | 6 | export default class SavedCuesRepo { 7 | addCue(performer: string, title: string, fileName: string, cue: string) { 8 | return db.savedCues.add({ 9 | performer, 10 | title, 11 | fileName, 12 | cue, 13 | createdAt: FieldValue.serverTimestamp() as firestore.Timestamp, 14 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/middleware/CORSMiddleware.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors'; 2 | import { allowedOrigins } from '../config'; 3 | 4 | const corsOptions = { 5 | preflightContinue: true, 6 | origin: ( 7 | requestOrigin: string | undefined, 8 | callback: (err: Error | null, origin?: boolean | string | RegExp | (string | RegExp)[] | undefined) => void 9 | ) => { 10 | callback(null, requestOrigin && allowedOrigins.includes(requestOrigin)); 11 | }, 12 | }; 13 | 14 | export default cors(corsOptions); 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Firebase Functions on merge 2 | "on": 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | build_and_deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - run: npm ci 12 | - name: Deploy to Firebase 13 | uses: w9jds/firebase-action@master 14 | with: 15 | args: deploy --only functions --message \"${{ github.event.head_commit.message }}\" --debug 16 | env: 17 | GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }} 18 | -------------------------------------------------------------------------------- /src/db/CounterRepo.ts: -------------------------------------------------------------------------------- 1 | import { firestore } from 'firebase-admin'; 2 | import db from './DbGateway'; 3 | 4 | const FieldValue = firestore.FieldValue; 5 | 6 | export default class CounterRepo { 7 | static readonly DOC_ID = 'counter-id'; 8 | private counterRef = db.counter.doc(CounterRepo.DOC_ID); 9 | 10 | async getCounter() { 11 | return (await this.counterRef.get()).data(); 12 | } 13 | 14 | async incrementCounter() { 15 | await this.counterRef.update({ value: FieldValue.increment(1) }); 16 | return this.getCounter(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [1.0.4](https://github.com/DmitryVarennikov/cuegenerator-server/compare/v1.0.3...v1.0.4) (2021-03-24) 6 | 7 | ### [1.0.3](https://github.com/DmitryVarennikov/cuegenerator-server/compare/v1.0.2...v1.0.3) (2021-03-23) 8 | 9 | ### [1.0.2](https://github.com/DmitryVarennikov/cuegenerator-server/compare/v1.0.1...v1.0.2) (2021-03-23) 10 | 11 | ### 1.0.1 (2021-03-23) 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | es6: true, 5 | node: true, 6 | }, 7 | extends: ['eslint:recommended', 'plugin:import/errors', 'plugin:import/warnings', 'plugin:import/typescript'], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { 10 | project: ['tsconfig.json', 'tsconfig.dev.json'], 11 | sourceType: 'module', 12 | }, 13 | ignorePatterns: [ 14 | '/lib/**/*', // Ignore built files. 15 | ], 16 | plugins: ['@typescript-eslint', 'import'], 17 | rules: { 18 | quotes: ['error', 'single'], 19 | 'no-unused-vars': 0, 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.FUNCTIONS_EMULATOR ? 'development' : 'production'; 2 | 3 | import 'source-map-support/register'; 4 | import { runWith } from 'firebase-functions'; 5 | import express from 'express'; 6 | import 'express-async-errors'; 7 | import router from './routes'; 8 | import errorMiddleware from './middleware/errorMiddleware'; 9 | import clientAuthMiddleware from './middleware/clientAuthMiddleware'; 10 | import CORSMiddleware from './middleware/CORSMiddleware'; 11 | import { API_SECRET } from './config'; 12 | 13 | const app = express(); 14 | app.use(express.json()); 15 | 16 | app.use(CORSMiddleware); 17 | // enable pre-flight for all routes 18 | app.options('*', CORSMiddleware); 19 | 20 | app.use('/counter', clientAuthMiddleware); 21 | app.use('/', router); 22 | 23 | app.use(errorMiddleware); 24 | 25 | export const api = runWith({ secrets: [API_SECRET.name] }).https.onRequest(app); 26 | -------------------------------------------------------------------------------- /src/middleware/clientAuthMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { logger } from 'firebase-functions'; 2 | import { Request, Response, NextFunction } from 'express'; 3 | import { verify } from 'jsonwebtoken'; 4 | import { API_SECRET } from '../config'; 5 | 6 | export default function clientAuthMiddleware(req: Request, res: Response, next: NextFunction) { 7 | if ('options' === req.method.toLocaleLowerCase()) { 8 | next(); 9 | return; 10 | } 11 | 12 | const token = extractToken(req.headers.authorization); 13 | try { 14 | verify(token, API_SECRET.value()); 15 | next(); 16 | } catch (e: unknown) { 17 | if (e instanceof Error && e.name !== 'TokenExpiredError') { 18 | logger.warn('Error while verifying jwt', { token, e }); 19 | } 20 | res.status(401).json({ error: (e as Error).message }); 21 | } 22 | } 23 | 24 | const extractToken = (bearerToken: string | undefined): string => (bearerToken || '').substring(7); 25 | -------------------------------------------------------------------------------- /src/db/DbGateway.ts: -------------------------------------------------------------------------------- 1 | import { firestore } from 'firebase-admin'; 2 | import { initializeApp } from 'firebase-admin/app'; 3 | import { Counter, Cue } from '../types'; 4 | import { DocumentData, QueryDocumentSnapshot, WithFieldValue } from 'firebase-admin/firestore'; 5 | 6 | initializeApp(); 7 | 8 | const converter = () => ({ 9 | toFirestore: (modelObject: WithFieldValue) => modelObject as DocumentData, 10 | fromFirestore: (snap: QueryDocumentSnapshot) => snap.data() as Model, 11 | }); 12 | 13 | const mount = (collectionPath: string) => 14 | firestore().collection(collectionPath).withConverter(converter()); 15 | 16 | // guard production db collection names 17 | const formatName = (name: string): string => 18 | process.env.NODE_ENV === 'production' ? name : `${name}_${process.env.NODE_ENV}`; 19 | 20 | const db = { 21 | counter: mount(formatName('counter')), 22 | savedCues: mount(formatName('savedCues')), 23 | }; 24 | 25 | export default db; 26 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { logger } from 'firebase-functions'; 2 | import express, { Request, Response } from 'express'; 3 | import { counterRepo, savedCuesRepo } from '../db'; 4 | import jwt from 'jsonwebtoken'; 5 | import { API_SECRET } from '../config'; 6 | 7 | const router = express.Router(); 8 | 9 | router.get('/', (req: Request, res: Response) => { 10 | const token = jwt.sign({}, API_SECRET.value(), { expiresIn: '1h' }); 11 | res.json({ token }); 12 | }); 13 | router.get('/counter', async (req: Request, res: Response) => { 14 | const counter = await counterRepo.getCounter(); 15 | res.json({ counter: counter?.value }); 16 | }); 17 | router.post('/counter', async (req: Request, res: Response) => { 18 | const { performer, title, fileName, cue } = req.body; 19 | let counter; 20 | 21 | if (performer && title && fileName && cue) { 22 | counter = await counterRepo.incrementCounter(); 23 | await savedCuesRepo.addCue(performer, title, fileName, cue); 24 | } else { 25 | counter = await counterRepo.getCounter(); 26 | } 27 | 28 | res.json({ counter: counter?.value }); 29 | logger.info('api.post', { counter: counter?.value, performer, title, fileName, cue }); 30 | }); 31 | 32 | export default router; 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # How to run locally 2 | 3 | ```bash 4 | npm run serve 5 | open http://localhost:5001/cuegenerator/us-central1/api 6 | ``` 7 | 8 | # How to build and deploy 9 | 10 | Automatic deployments use GitHub Actions and are triggered on push to the `main` branch if commit contains "(release):" string. To make a release run `npm run release`. That's it! `standard-version` will run the `postrelease` script in the `package.json` which will take care about the rest. 11 | 12 | A manual deployment can be run 13 | 14 | ```bash 15 | npm run deploy 16 | open https://us-central1-cuegenerator.cloudfunctions.net/api 17 | ``` 18 | 19 | # How to develop 20 | 21 | ## API protocol 22 | 23 | ### Obtain token 24 | 25 | Make a request to obtain a JSON web token to use in all subsequent requests. **Note** the expiration time is set to one hour. 26 | 27 | **Request** 28 | 29 | ``` 30 | GET / 31 | ``` 32 | 33 | **Response** 34 | 35 | ``` 36 | {"token": "..."} 37 | ``` 38 | 39 | ### Get counter value 40 | 41 | **Request** 42 | 43 | ``` 44 | GET /counter 45 | Authorization: Bearer 46 | ``` 47 | 48 | **Response** 49 | 50 | ``` 51 | {"counter": ""} 52 | ``` 53 | 54 | ### Send data 55 | 56 | Submit cue and bump up the counter 57 | 58 | **Request** 59 | 60 | ``` 61 | POST /counter 62 | 63 | Content-Type: application/json 64 | Authorization: Bearer 65 | 66 | { 67 | "performer": "", 68 | "title": "", 69 | "fileName": "", 70 | "cue": "" 71 | } 72 | ``` 73 | 74 | **Response** 75 | 76 | ``` 77 | {"counter": ""} 78 | ``` 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled JavaScript files 2 | lib/**/*.js 3 | lib/**/*.js.map 4 | 5 | # TypeScript v1 declaration files 6 | typings/ 7 | 8 | # Node.js dependency directory 9 | node_modules/ 10 | 11 | 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | firebase-debug.log* 19 | firebase-debug.*.log* 20 | 21 | # Firebase cache 22 | .firebase/ 23 | 24 | # Firebase config 25 | 26 | # Uncomment this if you'd like others to create their own Firebase project. 27 | # For a team working on the same Firebase project(s), it is recommended to leave 28 | # it commented so all members can deploy to the same project(s) in .firebaserc. 29 | # .firebaserc 30 | 31 | # Runtime data 32 | pids 33 | *.pid 34 | *.seed 35 | *.pid.lock 36 | .runtimeconfig.json 37 | 38 | # Directory for instrumented libs generated by jscoverage/JSCover 39 | lib-cov 40 | 41 | # Coverage directory used by tools like istanbul 42 | coverage 43 | 44 | # nyc test coverage 45 | .nyc_output 46 | 47 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 48 | .grunt 49 | 50 | # Bower dependency directory (https://bower.io/) 51 | bower_components 52 | 53 | # node-waf configuration 54 | .lock-wscript 55 | 56 | # Compiled binary addons (http://nodejs.org/api/addons.html) 57 | build/Release 58 | 59 | # Dependency directories 60 | node_modules/ 61 | 62 | # Optional npm cache directory 63 | .npm 64 | 65 | # Optional eslint cache 66 | .eslintcache 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variables file 78 | .env 79 | 80 | .vscode 81 | .secrets/* -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "version": "1.0.4", 4 | "scripts": { 5 | "lint": "eslint --ext .js,.ts .", 6 | "build": "tsc", 7 | "firebase": "firebase", 8 | "serve": "npm run build && npm run firebase functions:config:get > .runtimeconfig.json && npm run firebase emulators:start -- --only functions", 9 | "shell": "npm run build && npm run firebase functions:shell", 10 | "start": "npm run shell", 11 | "deploy": "npm run firebase deploy -- --only functions", 12 | "logs": "npm run firebase functions:log", 13 | "test": "npm run lint && npm run build", 14 | "release": "standard-version", 15 | "postrelease": "git push --follow-tags origin main" 16 | }, 17 | "engines": { 18 | "node": "20" 19 | }, 20 | "main": "lib/index.js", 21 | "dependencies": { 22 | "express": "^4.17.1", 23 | "express-async-errors": "^3.1.1", 24 | "firebase-admin": "^11.10.1", 25 | "firebase-functions": "^4.4.1", 26 | "jsonwebtoken": "^8.5.1", 27 | "source-map-support": "^0.5.21" 28 | }, 29 | "devDependencies": { 30 | "@types/cors": "^2.8.10", 31 | "@types/express": "^4.17.11", 32 | "@types/jsonwebtoken": "^8.5.0", 33 | "@types/source-map-support": "^0.5.7", 34 | "@typescript-eslint/eslint-plugin": "^3.9.1", 35 | "@typescript-eslint/parser": "^3.8.0", 36 | "eslint": "^7.6.0", 37 | "eslint-config-google": "^0.14.0", 38 | "eslint-plugin-import": "^2.22.0", 39 | "firebase-functions-test": "^0.2.0", 40 | "firebase-tools": "^12.5.4", 41 | "husky": "^4.3.8", 42 | "prettier": "^2.2.1", 43 | "standard-version": "^9.1.1", 44 | "typescript": "^5.2.2" 45 | }, 46 | "private": true, 47 | "husky": { 48 | "hooks": { 49 | "pre-push": "npm run test" 50 | } 51 | } 52 | } 53 | --------------------------------------------------------------------------------