diff --git a/scripts/src/createApiKeysCollection.ts b/scripts/src/createApiKeysCollection.ts new file mode 100644 index 00000000..0e391774 --- /dev/null +++ b/scripts/src/createApiKeysCollection.ts @@ -0,0 +1,53 @@ +import { MongoClient } from "mongodb"; +import config from "@api/config"; + +process.on("unhandledRejection", err => { + throw err; +}); + +const COLLECTION = "apikeys"; + +const validator = { + $jsonSchema: { + bsonType: "object", + required: ["hexathon", "provider", "key"], + properties: { + hexathon: { bsonType: "objectId" }, + provider: { bsonType: "string" }, + key: { bsonType: "string" }, + hexathonUser: { bsonType: "objectId" }, + claimedAt: { bsonType: "date" }, + }, + }, +}; + +const createApiKeysCollection = async () => { + const client = new MongoClient(config.database.mongo.uri); + await client.connect(); + const db = client.db(config.services.HEXATHONS.database.name); + + const existing = await db.listCollections({ name: COLLECTION }).toArray(); + if (existing.length === 0) { + await db.createCollection(COLLECTION, { validator }); + console.log(`Created collection ${COLLECTION}`); + } else { + await db.command({ collMod: COLLECTION, validator }); + console.log(`Updated validator on existing collection ${COLLECTION}`); + } + + await db.collection(COLLECTION).createIndexes([ + { key: { key: 1 }, unique: true, name: "key_1" }, + { key: { hexathon: 1, provider: 1 }, name: "hexathon_1_provider_1" }, + { + key: { hexathon: 1, provider: 1, hexathonUser: 1 }, + unique: true, + partialFilterExpression: { hexathonUser: { $type: "objectId" } }, + name: "hexathon_1_provider_1_hexathonUser_1", + }, + ]); + console.log("Created indexes"); + + await client.close(); +}; + +createApiKeysCollection(); diff --git a/services/hexathons/src/models/apiKey.ts b/services/hexathons/src/models/apiKey.ts new file mode 100644 index 00000000..639cee6f --- /dev/null +++ b/services/hexathons/src/models/apiKey.ts @@ -0,0 +1,49 @@ +import { AccessibleRecordModel, accessibleRecordsPlugin } from "@casl/mongoose"; +import mongoose, { Schema, model, Types } from "mongoose"; + +import { HexathonModel } from "./hexathon"; +import { HexathonUserModel } from "./hexathonUser"; + +export interface ApiKey extends mongoose.Document { + hexathon: Types.ObjectId; + provider: string; + key: string; + hexathonUser?: Types.ObjectId; + claimedAt?: Date; +} + +const apiKeySchema = new Schema({ + hexathon: { + type: Schema.Types.ObjectId, + required: true, + ref: HexathonModel, + }, + provider: { + type: String, + required: true, + default: "openai", + }, + key: { + type: String, + required: true, + unique: true, + }, + hexathonUser: { + type: Schema.Types.ObjectId, + ref: HexathonUserModel, + }, + claimedAt: { + type: Date, + }, +}); + +apiKeySchema.index({ hexathon: 1, provider: 1 }); + +apiKeySchema.index( + { hexathon: 1, provider: 1, hexathonUser: 1 }, + { unique: true, partialFilterExpression: { hexathonUser: { $type: "objectId" } } } +); + +apiKeySchema.plugin(accessibleRecordsPlugin); + +export const ApiKeyModel = model>("ApiKey", apiKeySchema); diff --git a/services/hexathons/src/permission.ts b/services/hexathons/src/permission.ts index 29ef0533..f1999d64 100644 --- a/services/hexathons/src/permission.ts +++ b/services/hexathons/src/permission.ts @@ -26,6 +26,7 @@ export const addAbilities = (): RequestHandler => (req, res, next) => { if (req.user.roles.admin || req.user.roles.exec) { can("manage", "Hexathon"); can("manage", "SwagItem"); + can("manage", "ApiKey"); } if (req.user.roles.admin || req.user.roles.member) { @@ -49,6 +50,7 @@ export const addAbilities = (): RequestHandler => (req, res, next) => { can("read", "SponsorVisit"); can("read", "Block"); can("read", "FoodBatch"); + can("read", "ApiKey"); req.ability = build(); next(); diff --git a/services/hexathons/src/routes/api-key.ts b/services/hexathons/src/routes/api-key.ts new file mode 100644 index 00000000..b8705ff5 --- /dev/null +++ b/services/hexathons/src/routes/api-key.ts @@ -0,0 +1,108 @@ +import express from "express"; +import { asyncHandler, BadRequestError, checkAbility } from "@api/common"; +import { FilterQuery } from "mongoose"; + +import { ApiKey, ApiKeyModel } from "../models/apiKey"; +import { HexathonUserModel } from "../models/hexathonUser"; + +export const apiKeyRouter = express.Router(); + +apiKeyRouter.route("/claim").post( + checkAbility("read", "ApiKey"), + asyncHandler(async (req, res) => { + if (!req.body.hexathon) { + throw new BadRequestError("Hexathon is required body field"); + } + + const filter = { + hexathon: String(req.body.hexathon), + provider: String(req.body.provider ?? "openai"), + }; + + const hexathonUser = await HexathonUserModel.findOne({ + userId: req.user?.uid, + hexathon: filter.hexathon, + }); + if (!hexathonUser) { + throw new BadRequestError("You are not checked in to this hexathon."); + } + + const existing = await ApiKeyModel.findOne({ ...filter, hexathonUser: hexathonUser.id }); + if (existing) { + return res.send(existing); + } + + let claimed; + try { + claimed = await ApiKeyModel.findOneAndUpdate( + { ...filter, hexathonUser: null }, + { hexathonUser: hexathonUser.id, claimedAt: new Date() }, + { new: true } + ); + } catch (err: any) { + if (err.code !== 11000) { + throw err; + } + return res.send(await ApiKeyModel.findOne({ ...filter, hexathonUser: hexathonUser.id })); + } + + if (!claimed) { + throw new BadRequestError("No API keys are available. Please contact an organizer."); + } + return res.send(claimed); + }) +); + +apiKeyRouter.route("/").get( + checkAbility("aggregate", "ApiKey"), + asyncHandler(async (req, res) => { + if (!req.query.hexathon) { + throw new BadRequestError("Hexathon is required parameter"); + } + + const filter: FilterQuery = { + hexathon: String(req.query.hexathon), + }; + if (req.query.provider) { + filter.provider = String(req.query.provider); + } + if (req.query.claimed === "true") { + filter.hexathonUser = { $type: "objectId" }; + } else if (req.query.claimed === "false") { + filter.hexathonUser = null; + } + + const keys = await ApiKeyModel.find(filter) + .populate("hexathonUser", "name email userId") + .sort({ claimedAt: -1 }); + const providers = await ApiKeyModel.distinct("provider", { + hexathon: String(req.query.hexathon), + }); + const claimed = keys.filter(apiKey => apiKey.hexathonUser).length; + + return res.send({ + total: keys.length, + claimed, + available: keys.length - claimed, + providers, + keys, + }); + }) +); + +apiKeyRouter.route("/").post( + checkAbility("create", "ApiKey"), + asyncHandler(async (req, res) => { + const { hexathon, keys } = req.body; + if (!hexathon || !Array.isArray(keys) || keys.length === 0) { + throw new BadRequestError("Hexathon and a non-empty keys array are required body fields"); + } + + const provider = String(req.body.provider ?? "openai"); + const created = await ApiKeyModel.insertMany( + keys.map((key: string) => ({ hexathon: String(hexathon), provider, key: String(key).trim() })) + ); + + return res.send({ inserted: created.length }); + }) +); diff --git a/services/hexathons/src/routes/index.ts b/services/hexathons/src/routes/index.ts index b2b846da..768c3dbb 100644 --- a/services/hexathons/src/routes/index.ts +++ b/services/hexathons/src/routes/index.ts @@ -12,6 +12,7 @@ import { sponsorVisitRouter } from "./sponsor-visit"; import { blockRoutes } from "./block"; import { teamRoutes } from "./team"; import { foodBatchRouter } from "./food-batch"; +import { apiKeyRouter } from "./api-key"; export const defaultRouter = express.Router(); @@ -27,3 +28,4 @@ defaultRouter.use("/sponsor-visit", sponsorVisitRouter); defaultRouter.use("/blocks", blockRoutes); defaultRouter.use("/teams", teamRoutes); defaultRouter.use("/food-batch", foodBatchRouter); +defaultRouter.use("/api-keys", apiKeyRouter);