feat: Add conference manager (#71)

This commit is contained in:
Thomas Durieux
2021-09-06 09:34:39 +02:00
committed by GitHub
parent 6dba366296
commit 49da267d5f
23 changed files with 2085 additions and 30 deletions

113
src/Conference.ts Normal file
View File

@@ -0,0 +1,113 @@
import AnonymizedRepositoryModel from "./database/anonymizedRepositories/anonymizedRepositories.model";
import { IConferenceDocument } from "./database/conference/conferences.types";
import Repository from "./Repository";
import { ConferenceStatus } from "./types";
export default class Conference {
private _data: IConferenceDocument;
private _repositories: Repository[] = null;
constructor(data: IConferenceDocument) {
this._data = data;
}
/**
* Update the status of the conference
* @param status the new status
* @param errorMessage a potential error message to display
*/
async updateStatus(status: ConferenceStatus, errorMessage?: string) {
this._data.status = status;
return this._data.save();
}
/**
* Check if the conference is expired
*/
isExpired() {
return this._data.endDate < new Date();
}
/**
* Expire the conference
*/
async expire() {
await this.updateStatus("expired");
await Promise.all(
(await this.repositories()).map(async (conf) => await conf.expire())
);
}
/**
* Remove the conference
*/
async remove() {
await this.updateStatus("removed");
await Promise.all(
(await this.repositories()).map(async (conf) => await conf.remove())
);
}
/**
* Returns the list of repositories of this conference
*
* @returns the list of repositories of this conference
*/
async repositories(): Promise<Repository[]> {
if (this._repositories) return this._repositories;
const repoIds = this._data.repositories
.filter((r) => !r.removeDate)
.map((r) => r.id)
.filter((f) => f);
this._repositories = (
await AnonymizedRepositoryModel.find({
_id: { $in: repoIds },
})
).map((r) => new Repository(r));
return this._repositories;
}
get ownerIDs() {
return this._data?.owners;
}
get quota() {
return this._data.plan.quota;
}
get status() {
return this._data.status;
}
toJSON(opt?: { billing: boolean }): any {
const pricePerHourPerRepo = this._data.plan.pricePerRepository / 30;
let price = 0;
const today =
new Date() > this._data.endDate ? this._data.endDate : new Date();
this._data.repositories.forEach((r) => {
const removeDate =
r.removeDate && r.removeDate < today ? r.removeDate : today;
price +=
(Math.max(removeDate.getTime() - r.addDate.getTime(), 0) /
1000 /
60 /
60 /
24) *
pricePerHourPerRepo;
});
return {
conferenceID: this._data.conferenceID,
name: this._data.name,
url: this._data.url,
startDate: this._data.startDate,
endDate: this._data.endDate,
status: this._data.status,
billing: this._data.billing,
options: this._data.options,
plan: this._data.plan,
price,
nbRepositories: this._data.repositories.filter((r) => !r.removeDate)
.length,
};
}
}

View File

@@ -11,6 +11,8 @@ import UserModel from "./database/users/users.model";
import { IAnonymizedRepositoryDocument } from "./database/anonymizedRepositories/anonymizedRepositories.types";
import { anonymizeStream } from "./anonymize-utils";
import GitHubBase from "./source/GitHubBase";
import Conference from "./Conference";
import ConferenceModel from "./database/conference/conferences.model";
export default class Repository {
private _model: IAnonymizedRepositoryDocument;
@@ -247,6 +249,21 @@ export default class Repository {
return this._model.size;
}
/**
* Returns the conference of the repository
*
* @returns conference of the repository
*/
async conference(): Promise<Conference | null> {
if (!this._model.conference) {
return null;
}
const conference = await ConferenceModel.findOne({
conferenceID: this._model.conference,
});
return new Conference(conference);
}
/***** Getters ********/
get repoId() {

View File

@@ -0,0 +1,12 @@
import * as mongoose from "mongoose";
const { model } = mongoose;
import { IConferenceDocument, IConferenceModel } from "./conferences.types";
import ConferenceSchema from "./conferences.schema";
const ConferenceModel = model<IConferenceDocument>(
"Conference",
ConferenceSchema
) as IConferenceModel;
export default ConferenceModel;

View File

@@ -0,0 +1,59 @@
import * as mongoose from "mongoose";
const { Schema } = mongoose;
const RepositorySchema = new Schema({
name: String,
conferenceID: {
type: String,
index: { unique: true },
},
url: String,
startDate: Date,
endDate: Date,
status: String,
owners: { type: [mongoose.Schema.Types.ObjectId] },
repositories: {
type: [
{
id: { type: mongoose.Schema.Types.ObjectId },
addDate: { type: Date },
removeDate: { type: Date },
},
],
},
options: {
expirationMode: String,
expirationDate: Date,
update: Boolean,
image: Boolean,
pdf: Boolean,
notebook: Boolean,
link: Boolean,
page: Boolean,
},
dateOfEntry: {
type: Date,
default: new Date(),
},
plan: {
planID: String,
pricePerRepository: Number,
quota: {
repository: Number,
size: Number,
file: Number,
},
},
billing: {
name: String,
email: String,
address: String,
address2: String,
city: String,
zip: String,
country: String,
vat: String,
},
});
export default RepositorySchema;

View File

@@ -0,0 +1,49 @@
import * as mongoose from "mongoose";
import { ConferenceStatus } from "../../types";
export interface IConference {
name: string;
conferenceID: string;
startDate: Date;
endDate: Date;
url: string;
status: ConferenceStatus;
owners: string[];
repositories: {
id: string;
addDate: Date;
removeDate?: Date;
}[];
options: {
expirationMode: "never" | "redirect" | "remove";
expirationDate?: Date;
update: boolean;
image: boolean;
pdf: boolean;
notebook: boolean;
link: boolean;
page: boolean;
};
plan: {
planID: string;
pricePerRepository: number;
quota: {
repository: number;
size: number;
file: number;
};
};
billing?: {
name: string;
email: string;
address: string;
address2?: string;
city: string;
zip: string;
country: string;
vat?: string;
};
}
export interface IConferenceDocument extends IConference, mongoose.Document {}
export interface IConferenceModel extends mongoose.Model<IConferenceDocument> {}

199
src/routes/conference.ts Normal file
View File

@@ -0,0 +1,199 @@
import * as express from "express";
import config from "../../config";
import Conference from "../Conference";
import AnonymizedRepositoryModel from "../database/anonymizedRepositories/anonymizedRepositories.model";
import ConferenceModel from "../database/conference/conferences.model";
import Repository from "../Repository";
import { ensureAuthenticated } from "./connection";
import { handleError, getUser } from "./route-utils";
const router = express.Router();
// user needs to be connected for all user API
router.use(ensureAuthenticated);
const plans = [
{
id: "free_conference",
name: "Free",
pricePerRepo: 0,
storagePerRepo: -1,
description: `<li><strong>Quota is deducted from user account</strong></li>
<li>No-download</li>
<li>Conference dashboard</li>`,
},
{
id: "premium_conference",
name: "Premium",
pricePerRepo: 0.5,
storagePerRepo: 500 * 8 * 1024,
description: `<li>500Mo / repository</li>
<li>Repository download</li>
<li>Conference dashboard</li>`,
},
{
id: "unlimited_conference",
name: "Unlimited",
pricePerRepo: 3,
storagePerRepo: 0,
description: `<li><strong>Unlimited</strong> repository size</li>
<li>Repository download</li>
<li>Conference dashboard</li>`,
},
];
router.get("/plans", async (req: express.Request, res: express.Response) => {
res.json(plans);
});
router.get("/", async (req: express.Request, res: express.Response) => {
try {
const user = await getUser(req);
const conferences = await Promise.all(
(
await ConferenceModel.find({
owners: { $in: user.model.id },
})
).map(async (data) => {
const conf = new Conference(data);
if (data.endDate < new Date() && data.status == "ready") {
await conf.updateStatus("expired");
}
return conf;
})
);
res.json(conferences.map((conf) => conf.toJSON()));
} catch (error) {
handleError(error, res);
}
});
function validateConferenceForm(conf) {
if (!conf.name) throw new Error("conf_name_missing");
if (!conf.conferenceID) throw new Error("conf_id_missing");
if (!conf.startDate) throw new Error("conf_start_date_missing");
if (!conf.endDate) throw new Error("conf_end_date_missing");
if (new Date(conf.startDate) > new Date(conf.endDate))
throw new Error("conf_start_date_invalid");
if (new Date() > new Date(conf.endDate))
throw new Error("conf_end_date_invalid");
if (plans.filter((p) => p.id == conf.plan.planID).length != 1)
throw new Error("invalid_plan");
const plan = plans.filter((p) => p.id == conf.plan.planID)[0];
if (plan.pricePerRepo > 0) {
const billing = conf.billing;
if (!billing) throw new Error("billing_missing");
if (!billing.name) throw new Error("billing_name_missing");
if (!billing.email) throw new Error("billing_email_missing");
if (!billing.address) throw new Error("billing_address_missing");
if (!billing.city) throw new Error("billing_city_missing");
if (!billing.zip) throw new Error("billing_zip_missing");
if (!billing.country) throw new Error("billing_country_missing");
}
}
router.post(
"/:conferenceID?",
async (req: express.Request, res: express.Response) => {
try {
const user = await getUser(req);
let model = new ConferenceModel();
if (req.params.conferenceID) {
model = await ConferenceModel.findOne({
conferenceID: req.params.conferenceID,
});
if (model.owners.indexOf(user.model.id) == -1)
throw new Error("not_authorized");
}
validateConferenceForm(req.body);
model.name = req.body.name;
model.startDate = new Date(req.body.startDate);
model.endDate = new Date(req.body.endDate);
model.status = "ready";
model.url = req.body.url;
model.repositories = [];
model.options = req.body.options;
if (!req.params.conferenceID) {
model.owners.push(user.model.id);
model.conferenceID = req.body.conferenceID;
model.plan = {
planID: req.body.plan.planID,
pricePerRepository: plans.filter(
(p) => p.id == req.body.plan.planID
)[0].pricePerRepo,
quota: {
size: plans.filter((p) => p.id == req.body.plan.planID)[0]
.storagePerRepo,
file: 0,
repository: 0,
},
};
if (req.body.billing)
model.billing = {
name: req.body.billing.name,
email: req.body.billing.email,
address: req.body.billing.address,
address2: req.body.billing.address2,
city: req.body.billing.city,
zip: req.body.billing.zip,
country: req.body.billing.country,
vat: req.body.billing.vat,
};
}
await model.save();
res.send("ok");
} catch (error) {
if (error.message?.indexOf(" duplicate key") > -1) {
return handleError(new Error("conf_id_used"), res);
}
handleError(error, res);
}
}
);
router.get(
"/:conferenceID",
async (req: express.Request, res: express.Response) => {
try {
const user = await getUser(req);
const data = await ConferenceModel.findOne({
conferenceID: req.params.conferenceID,
});
if (!data) throw new Error("conf_not_found");
const conference = new Conference(data);
if (conference.ownerIDs.indexOf(user.model.id) == -1)
throw new Error("not_authorized");
const o: any = conference.toJSON();
o.repositories = (await conference.repositories()).map((r) => r.toJSON());
res.json(o);
} catch (error) {
handleError(error, res);
}
}
);
router.delete(
"/:conferenceID",
async (req: express.Request, res: express.Response) => {
try {
const user = await getUser(req);
const data = await ConferenceModel.findOne({
conferenceID: req.params.conferenceID,
});
if (!data) throw new Error("conf_not_found");
const conference = new Conference(data);
if (conference.ownerIDs.indexOf(user.model.id) == -1)
throw new Error("not_authorized");
await conference.remove();
res.send("ok");
} catch (error) {
handleError(error, res);
}
}
);
export default router;

View File

@@ -1,5 +1,6 @@
import repositoryPrivate from "./repository-private";
import repositoryPublic from "./repository-public";
import conference from "./conference";
import file from "./file";
import webview from "./webview";
import user from "./user";
@@ -12,4 +13,5 @@ export default {
webview,
user,
option,
conference
};

View File

@@ -10,6 +10,7 @@ import AnonymizedRepositoryModel from "../database/anonymizedRepositories/anonym
import config from "../../config";
import { IAnonymizedRepositoryDocument } from "../database/anonymizedRepositories/anonymizedRepositories.types";
import Repository from "../Repository";
import ConferenceModel from "../database/conference/conferences.model";
const router = express.Router();
@@ -61,7 +62,7 @@ router.post(
async (req: express.Request, res: express.Response) => {
try {
const repo = await getRepo(req, res, { nocheck: true });
if (!repo) throw new Error("repo_not_found");
if (!repo) return;
const user = await getUser(req);
if (repo.owner.username != user.username) {
@@ -163,7 +164,7 @@ router.get(
router.get("/:repoId/", async (req: express.Request, res: express.Response) => {
try {
const repo = await getRepo(req, res, { nocheck: true });
if (!repo) throw new Error("repo_not_found");
if (!repo) return;
const user = await getUser(req);
if (user.username != repo.model.owner) {
@@ -215,7 +216,6 @@ function updateRepoModel(
}
model.source.commit = repoUpdate.source.commit;
model.source.branch = repoUpdate.source.branch;
model.conference = repoUpdate.conference;
model.options = {
terms: repoUpdate.terms,
expirationMode: repoUpdate.options.expirationMode,
@@ -238,7 +238,7 @@ router.post(
async (req: express.Request, res: express.Response) => {
try {
const repo = await getRepo(req, res, { nocheck: true });
if (!repo) throw new Error("repo_not_found");
if (!repo) return;
const user = await getUser(req);
if (repo.owner.username != user.username) {
@@ -257,6 +257,48 @@ router.post(
updateRepoModel(repo.model, repoUpdate);
async function removeRepoFromConference(conferenceID) {
const conf = await ConferenceModel.findOne({
conferenceID,
});
if (conf) {
const r = conf.repositories.filter((r) => r.id == repo.model.id);
if (r.length == 1) r[0].removeDate = new Date();
await conf.save();
}
}
if (!repoUpdate.conference) {
// remove conference
if (repo.model.conference) {
await removeRepoFromConference(repo.model.conference);
}
} else if (repoUpdate.conference != repo.model.conference) {
// update/add conference
const conf = await ConferenceModel.findOne({
conferenceID: repoUpdate.conference,
});
if (conf) {
if (new Date() < conf.startDate || new Date() > conf.endDate || conf.status !== "ready") {
throw new Error("conf_not_activated");
}
const f = conf.repositories.filter((r) => r.id == repo.model.id);
if (f.length) {
// the repository already referenced the conference
f[0].addDate = new Date();
f[0].removeDate = null;
} else {
conf.repositories.push({
id: repo.model.id,
addDate: new Date(),
});
}
if (repo.model.conference) {
await removeRepoFromConference(repo.model.conference);
}
await conf.save();
}
}
repo.model.conference = repoUpdate.conference;
await repo.updateStatus("preparing");
res.send("ok");
new Repository(repo.model).anonymize();
@@ -285,7 +327,7 @@ router.post("/", async (req: express.Request, res: express.Response) => {
repo.repoId = repoUpdate.repoId;
repo.anonymizeDate = new Date();
repo.owner = user.username;
updateRepoModel(repo, repoUpdate);
repo.source.accessToken = user.accessToken;
repo.source.repositoryId = repository.model.id;
@@ -297,8 +339,27 @@ router.post("/", async (req: express.Request, res: express.Response) => {
return res.status(500).send({ error: "invalid_mode" });
}
}
repo.conference = repoUpdate.conference;
await repo.save();
if (repoUpdate.conference) {
const conf = await ConferenceModel.findOne({
conferenceID: repoUpdate.conference,
});
if (conf) {
if (new Date() < conf.startDate || new Date() > conf.endDate || conf.status !== "ready") {
await repo.remove()
throw new Error("conf_not_activated");
}
conf.repositories.push({
id: repo.id,
addDate: new Date(),
});
await conf.save();
}
}
res.send("ok");
new Repository(repo).anonymize();
} catch (error) {

View File

@@ -48,7 +48,7 @@ router.get(
async (req: express.Request, res: express.Response) => {
try {
const repo = await getRepo(req, res, { nocheck: true });
if (!repo) throw new Error("repo_not_found");
if (!repo) return;
let redirectURL = null;
if (
repo.status == "expired" &&
@@ -62,10 +62,19 @@ router.get(
await repo.updateIfNeeded();
let download = false;
const conference = await repo.conference();
if (conference) {
console.log(conference.quota)
download =
conference.quota.size > -1 &&
!!config.ENABLE_DOWNLOAD &&
repo.source.type == "GitHubDownload";
}
res.json({
url: redirectURL,
download:
!!config.ENABLE_DOWNLOAD && repo.source.type == "GitHubDownload",
download,
});
} catch (error) {
handleError(error, res);

17
src/schedule.ts Normal file
View File

@@ -0,0 +1,17 @@
import * as schedule from "node-schedule";
import Conference from "./Conference";
import ConferenceModel from "./database/conference/conferences.model";
export function conferenceStatusCheck() {
// check every 6 hours the status of the conference
const job = schedule.scheduleJob("0 */6 * * *", async () => {
(await ConferenceModel.find({ status: { $eq: "ready" } })).forEach(
async (data) => {
const conference = new Conference(data);
if (conference.isExpired() && conference.status == "ready") {
await conference.expire();
}
}
);
});
}

View File

@@ -12,6 +12,7 @@ import * as passport from "passport";
import * as connection from "./routes/connection";
import router from "./routes";
import AnonymizedRepositoryModel from "./database/anonymizedRepositories/anonymizedRepositories.model";
import { conferenceStatusCheck } from "./schedule";
function indexResponse(req: express.Request, res: express.Response) {
if (
@@ -58,6 +59,7 @@ export default async function start() {
// api routes
app.use("/api/options", rate, router.option);
app.use("/api/conferences", rate, router.conference);
app.use("/api/user", rate, router.user);
app.use("/api/repo", rate, router.repositoryPublic);
app.use("/api/repo", rate, router.file);
@@ -96,6 +98,9 @@ export default async function start() {
app.get("*", indexResponse);
// start schedules
conferenceStatusCheck();
await db.connect();
app.listen(config.PORT);
console.log("Database connected and Server started on port: " + config.PORT);

View File

@@ -117,6 +117,8 @@ export type RepositoryStatus =
| "expired"
| "removed";
export type ConferenceStatus = "ready" | "expired" | "removed";
export type SourceStatus = "available" | "unavailable";
export type TreeElement = Tree | TreeFile;