account export
parent
7f0369b0d5
commit
cbc916088c
|
@ -12,7 +12,8 @@ import {
|
||||||
isUsernameValid,
|
isUsernameValid,
|
||||||
} from "../validator/validator";
|
} from "../validator/validator";
|
||||||
import {
|
import {
|
||||||
ACCOUNT_DEMO_DAYS,
|
ACCOUNT_EXPORT_EXPIRY,
|
||||||
|
ACCOUNT_EXPORT_URL,
|
||||||
ACCOUNT_STATE,
|
ACCOUNT_STATE,
|
||||||
CALENDAR_MAX_SERVICE_DURATION,
|
CALENDAR_MAX_SERVICE_DURATION,
|
||||||
CALENDAR_MIN_EARLIEST_BOOKING_TIME,
|
CALENDAR_MIN_EARLIEST_BOOKING_TIME,
|
||||||
|
@ -53,6 +54,7 @@ import {
|
||||||
isTerminPlanerGoogleCalendarConnected,
|
isTerminPlanerGoogleCalendarConnected,
|
||||||
terminPlanerRequest,
|
terminPlanerRequest,
|
||||||
} from "../utils/terminPlaner";
|
} from "../utils/terminPlaner";
|
||||||
|
import UserAccountExport from "../models/userAccountExport";
|
||||||
|
|
||||||
export async function SignUp(req: Request, res: Response) {
|
export async function SignUp(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
|
@ -505,26 +507,6 @@ export async function GetUser(req: Request, res: Response) {
|
||||||
"calendar",
|
"calendar",
|
||||||
"paymentPlan"
|
"paymentPlan"
|
||||||
);
|
);
|
||||||
|
|
||||||
// calc account plan expiry by created_at + demo days
|
|
||||||
|
|
||||||
const accountPlanExpiry = new Date(user.created_at);
|
|
||||||
|
|
||||||
accountPlanExpiry.setDate(
|
|
||||||
accountPlanExpiry.getDate() + ACCOUNT_DEMO_DAYS
|
|
||||||
);
|
|
||||||
|
|
||||||
respData.user = {
|
|
||||||
...respData.user,
|
|
||||||
account_plan_expiry: accountPlanExpiry,
|
|
||||||
} as {
|
|
||||||
user_id: string;
|
|
||||||
username: string;
|
|
||||||
language: string;
|
|
||||||
analytics_enabled: boolean;
|
|
||||||
account_plan_expiry: Date;
|
|
||||||
payment_plan: number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// update user session last_used
|
// update user session last_used
|
||||||
|
@ -1035,6 +1017,8 @@ export async function DeleteUserProfile(req: Request, res: Response) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This will create a new user account export and send the user an email with a link to download the exported account data
|
||||||
|
// There is no export file created, the file will be created when the user clicks the link in the email
|
||||||
export async function ExportUserAccount(req: Request, res: Response) {
|
export async function ExportUserAccount(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { email, password } = req.body;
|
const { email, password } = req.body;
|
||||||
|
@ -1042,15 +1026,17 @@ export async function ExportUserAccount(req: Request, res: Response) {
|
||||||
if (
|
if (
|
||||||
!email ||
|
!email ||
|
||||||
!password ||
|
!password ||
|
||||||
!(await isEmailValid(email)) ||
|
!(await isEmailValid(email, false)) ||
|
||||||
!isPasswordValid(password)
|
!isPasswordValid(password)
|
||||||
) {
|
) {
|
||||||
|
logger.error("export user account error", "invalid request");
|
||||||
return res.status(400).send({ err: "invalid request" });
|
return res.status(400).send({ err: "invalid request" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await getUserSession(req);
|
const session = await getUserSession(req);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
logger.error("export user account error", "unauthorized");
|
||||||
return res.status(401).send({ err: "unauthorized" });
|
return res.status(401).send({ err: "unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1058,9 +1044,11 @@ export async function ExportUserAccount(req: Request, res: Response) {
|
||||||
where: {
|
where: {
|
||||||
user_id: session.user_id,
|
user_id: session.user_id,
|
||||||
},
|
},
|
||||||
|
attributes: ["password"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
logger.error("export user account error", "unauthorized");
|
||||||
return res.status(401).send({ err: "unauthorized" });
|
return res.status(401).send({ err: "unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1069,13 +1057,23 @@ export async function ExportUserAccount(req: Request, res: Response) {
|
||||||
const match = await matchPassword(decodedPassword, user.password);
|
const match = await matchPassword(decodedPassword, user.password);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
logger.error("export user account error", "invalid password");
|
||||||
return res.status(400).send({ err: "invalid request" });
|
return res.status(400).send({ err: "invalid request" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountExportId = newAccountExportId();
|
||||||
|
|
||||||
|
await UserAccountExport.create({
|
||||||
|
export_id: accountExportId,
|
||||||
|
user_id: session.user_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
// create json file with user data
|
// create json file with user data
|
||||||
|
|
||||||
const exportData = {
|
const exportData = {
|
||||||
user: {
|
user: {
|
||||||
user_id: user.user_id,
|
user_id: user.user_id,
|
||||||
|
@ -1092,29 +1090,40 @@ export async function ExportUserAccount(req: Request, res: Response) {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const accountExportId = newAccountExportId();
|
|
||||||
|
|
||||||
fs.writeJson(
|
fs.writeJson(
|
||||||
`./user-profile-exports/${accountExportId}.json`,
|
`./user-profile-exports/${accountExportId}.json`,
|
||||||
exportData
|
exportData
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const accountExportId = newAccountExportId();
|
||||||
|
|
||||||
// send email with file
|
// send email with file
|
||||||
|
|
||||||
rabbitmq.sendEmail(email, "dashboardUserAccountExportFinish", "de", {
|
rabbitmq.sendEmail(email, "dashboardUserAccountExportFinish", "de", {
|
||||||
accountExportDownloadUrl: `${
|
accountExportDownloadUrl: `${ACCOUNT_EXPORT_URL}${accountExportId}`,
|
||||||
process.env.ACCOUNT_EXPORT_URL as string
|
|
||||||
}${accountExportId}`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
userLogger.info(
|
userLogger.info(
|
||||||
session.user_id,
|
session.user_id,
|
||||||
"User account exported and sent via email"
|
"User account exported and sent via email"
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("export user account error", error as string);
|
logger.error("export user account error", error as string);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
*/
|
||||||
|
|
||||||
|
// send email for the user to download his exported account data
|
||||||
|
|
||||||
|
rabbitmq.sendEmail(email, "dashboardUserAccountExportFinish", "de", {
|
||||||
|
accountExportDownloadUrl: `${ACCOUNT_EXPORT_URL}${accountExportId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
userLogger.info(
|
||||||
|
session.user_id,
|
||||||
|
"User account exported and sent via email"
|
||||||
|
);
|
||||||
|
|
||||||
userLogger.info(session.user_id, "User requested account export");
|
userLogger.info(session.user_id, "User requested account export");
|
||||||
|
|
||||||
|
@ -1125,27 +1134,114 @@ export async function ExportUserAccount(req: Request, res: Response) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This will create a new user account export file and send it to the user and then delete the file
|
||||||
export async function GetExportedUserAccount(req: Request, res: Response) {
|
export async function GetExportedUserAccount(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
logger.warn(
|
||||||
|
"GetExportedUserAccount error",
|
||||||
|
"invalid request (id missing)"
|
||||||
|
);
|
||||||
return res.status(400).send({ err: "invalid request" });
|
return res.status(400).send({ err: "invalid request" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = `./user-profile-exports/${id}.json`;
|
// check if user account export exists in database
|
||||||
|
|
||||||
res.download(file, (err) => {
|
const userAccountExport = await UserAccountExport.findOne({
|
||||||
if (err) {
|
where: {
|
||||||
res.status(500).send({ err: "invalid request" });
|
export_id: id,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// delete file after download
|
if (!userAccountExport) {
|
||||||
|
logger.warn(
|
||||||
|
"GetExportedUserAccount error",
|
||||||
|
"invalid request (user account export not found)"
|
||||||
|
);
|
||||||
|
return res.status(400).send({ err: "invalid request" });
|
||||||
|
}
|
||||||
|
|
||||||
fs.remove(file);
|
if (
|
||||||
|
userAccountExport.created_at <
|
||||||
|
new Date(Date.now() - ACCOUNT_EXPORT_EXPIRY)
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
"GetExportedUserAccount error",
|
||||||
|
"invalid request (user account export expired)"
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(`User account export downloaded: ${id}`);
|
// delete expired user account export
|
||||||
|
|
||||||
|
await UserAccountExport.destroy({
|
||||||
|
where: {
|
||||||
|
export_id: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(400).send({ err: "invalid request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: {
|
||||||
|
user_id: userAccountExport.user_id,
|
||||||
|
},
|
||||||
|
attributes: [
|
||||||
|
"user_id",
|
||||||
|
"username",
|
||||||
|
"email",
|
||||||
|
"calendar_max_future_booking_days",
|
||||||
|
"calendar_min_earliest_booking_time",
|
||||||
|
"calendar_using_primary_calendar",
|
||||||
|
"language",
|
||||||
|
"analytics_enabled",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.error("export user account error", "unauthorized");
|
||||||
|
return res.status(401).send({ err: "unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = `./user-profile-exports/${id}.json`;
|
||||||
|
|
||||||
|
const exportData = {
|
||||||
|
user: {
|
||||||
|
user_id: user.user_id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
calendar_max_future_booking_days: user.calendar_max_future_booking_days,
|
||||||
|
calendar_min_earliest_booking_time:
|
||||||
|
user.calendar_min_earliest_booking_time,
|
||||||
|
calendar_using_primary_calendar: user.calendar_using_primary_calendar,
|
||||||
|
language: user.language,
|
||||||
|
analytics_enabled: user.analytics_enabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// spaces is for pretty print
|
||||||
|
await fs.writeJson(filePath, exportData, { spaces: 2 });
|
||||||
|
|
||||||
|
res.download(filePath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
res.status(500).send({ err: "invalid request" });
|
||||||
|
} else {
|
||||||
|
// delete file after successful download
|
||||||
|
fs.remove(filePath)
|
||||||
|
.then(() => {
|
||||||
|
logger.info(
|
||||||
|
`User account export downloaded and file deleted: ${id}`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((deleteError) => {
|
||||||
|
logger.error(
|
||||||
|
"Error deleting file after download",
|
||||||
|
deleteError as string
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("get exported user account error", error as string);
|
logger.error("get exported user account error", error as string);
|
||||||
res.status(500).send({ err: "invalid request" });
|
res.status(500).send({ err: "invalid request" });
|
||||||
|
@ -1420,36 +1516,3 @@ export async function UpdateUserProfileEmail(req: Request, res: Response) {
|
||||||
res.status(500).send({ err: "invalid request" });
|
res.status(500).send({ err: "invalid request" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GetDemo(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const demoAccount = await User.findOne({
|
|
||||||
where: {
|
|
||||||
user_id: process.env.DEMO_ACCOUNT_USER_ID as string,
|
|
||||||
},
|
|
||||||
attributes: ["user_id", "email", "language"],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!demoAccount) {
|
|
||||||
logger.error("get demo error", "demo account not found");
|
|
||||||
return res.status(400).send({ err: "invalid request" });
|
|
||||||
}
|
|
||||||
|
|
||||||
rabbitmq.sendEmail(
|
|
||||||
demoAccount.email,
|
|
||||||
"dashboardSecurityInfoNewAccountLogin",
|
|
||||||
demoAccount.language,
|
|
||||||
{
|
|
||||||
os: getUserAgentOS(req),
|
|
||||||
email: demoAccount.email,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
userLogger.info(demoAccount.user_id, "A demo account was requested");
|
|
||||||
|
|
||||||
saveSession(req, res, demoAccount.user_id, true);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("get demo error", error as string);
|
|
||||||
res.status(500).send({ err: "invalid request" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -8,16 +8,18 @@ import StoreServiceActivityUsers from "./storeServiceActivityUsers";
|
||||||
import User from "./user";
|
import User from "./user";
|
||||||
import UserPendingEmailChange from "./userPendingEmailChange";
|
import UserPendingEmailChange from "./userPendingEmailChange";
|
||||||
import UserPendingPayment from "./userPendingPayment";
|
import UserPendingPayment from "./userPendingPayment";
|
||||||
|
import UserAccountExport from "./userAccountExport";
|
||||||
|
|
||||||
function syncModels() {
|
function syncModels() {
|
||||||
EmailVerification.sync();
|
EmailVerification.sync();
|
||||||
User.sync();
|
Feedback.sync();
|
||||||
Session.sync();
|
Session.sync();
|
||||||
Store.sync();
|
Store.sync();
|
||||||
StoreService.sync();
|
StoreService.sync();
|
||||||
StoreServiceActivity.sync();
|
StoreServiceActivity.sync();
|
||||||
StoreServiceActivityUsers.sync();
|
StoreServiceActivityUsers.sync();
|
||||||
Feedback.sync();
|
User.sync();
|
||||||
|
UserAccountExport.sync();
|
||||||
UserPendingEmailChange.sync();
|
UserPendingEmailChange.sync();
|
||||||
UserPendingPayment.sync();
|
UserPendingPayment.sync();
|
||||||
|
|
||||||
|
|
|
@ -94,7 +94,7 @@ User.init(
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
},
|
},
|
||||||
google_account_picture: {
|
google_account_picture: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING(1050),
|
||||||
allowNull: true, // varchar(1050) needs to be set manually
|
allowNull: true, // varchar(1050) needs to be set manually
|
||||||
},
|
},
|
||||||
analytics_enabled: {
|
analytics_enabled: {
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { DataTypes, Model } from "sequelize";
|
||||||
|
import sequelize from "../database/database";
|
||||||
|
import { ACCOUNT_EXPORT_ID_LENGTH } from "../utils/constants";
|
||||||
|
|
||||||
|
interface UserAccountExportAttributes {
|
||||||
|
export_id: string;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserAccountExport
|
||||||
|
extends Model<UserAccountExportAttributes>
|
||||||
|
implements UserAccountExportAttributes
|
||||||
|
{
|
||||||
|
declare export_id: string;
|
||||||
|
declare user_id: string;
|
||||||
|
declare created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
UserAccountExport.init(
|
||||||
|
{
|
||||||
|
export_id: {
|
||||||
|
primaryKey: true,
|
||||||
|
type: DataTypes.STRING(ACCOUNT_EXPORT_ID_LENGTH * 2),
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: "user_account_export",
|
||||||
|
createdAt: "created_at",
|
||||||
|
updatedAt: "updated_at",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default UserAccountExport;
|
|
@ -28,7 +28,7 @@ UserPendingPayment.init(
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
payment_session_url: {
|
payment_session_url: {
|
||||||
type: DataTypes.STRING, // varchar(1000) needs to be set manually
|
type: DataTypes.STRING(1000), // varchar(1000) needs to be set manually
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -52,6 +52,5 @@ router.post(
|
||||||
);
|
);
|
||||||
router.get("/profile/export/:id", userController.GetExportedUserAccount);
|
router.get("/profile/export/:id", userController.GetExportedUserAccount);
|
||||||
router.post("/verify/:state/:emailVerificationId", userController.VerifyEmail);
|
router.post("/verify/:state/:emailVerificationId", userController.VerifyEmail);
|
||||||
router.get("/demo", userController.GetDemo);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import Stripe from "stripe";
|
|
||||||
|
|
||||||
export const DEFAULT_SESSION_EXPIRY = 365 * 24 * 60 * 60 * 1000; // 365 days
|
export const DEFAULT_SESSION_EXPIRY = 365 * 24 * 60 * 60 * 1000; // 365 days
|
||||||
export const SESSION_EXPIRY_NOT_REMEMBER_ME = 60 * 60 * 1000; // 1 hour
|
export const SESSION_EXPIRY_NOT_REMEMBER_ME = 60 * 60 * 1000; // 1 hour
|
||||||
export const USER_SESSION_LENGTH = 32;
|
export const USER_SESSION_LENGTH = 32;
|
||||||
|
export const ACCOUNT_EXPORT_ID_LENGTH = 64;
|
||||||
|
export const ACCOUNT_EXPORT_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
export const USERNAME_MIN_LENGTH = 3;
|
export const USERNAME_MIN_LENGTH = 3;
|
||||||
export const USERNAME_MAX_LENGTH = 20;
|
export const USERNAME_MAX_LENGTH = 20;
|
||||||
|
@ -71,10 +71,6 @@ export const Roles = {
|
||||||
Worker: "worker",
|
Worker: "worker",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ACCOUNT_DEMO_DAYS = Number(
|
|
||||||
process.env.CONSTANTS_ACCOUNT_DEMO_DAYS
|
|
||||||
); // how many days a demo account is valid until payment is required or account will be deleted
|
|
||||||
|
|
||||||
export enum EMAIL_VERIFICATION_STATE {
|
export enum EMAIL_VERIFICATION_STATE {
|
||||||
PENDING_EMAIL_VERIFICATION = 0, // account is created but email is not verified yet
|
PENDING_EMAIL_VERIFICATION = 0, // account is created but email is not verified yet
|
||||||
PENDING_USER_PROFILE_EMAIL_CHANGE_VERIFICATION = 1, // user wants to change email, new email is not verified yet
|
PENDING_USER_PROFILE_EMAIL_CHANGE_VERIFICATION = 1, // user wants to change email, new email is not verified yet
|
||||||
|
@ -85,7 +81,7 @@ export const DASHBOARD_URL = process.env.DASHBOARD_URL as string;
|
||||||
export const GOOGLE_CALLBACK_URL = `${process.env.DASHBOARD_API_URL}/v1/calendar/auth/google/callback`;
|
export const GOOGLE_CALLBACK_URL = `${process.env.DASHBOARD_API_URL}/v1/calendar/auth/google/callback`;
|
||||||
export const PASSPORT_FAILURE_REDIRECT_URL = `${DASHBOARD_URL}/store/calendar/auth/failed`;
|
export const PASSPORT_FAILURE_REDIRECT_URL = `${DASHBOARD_URL}/store/calendar/auth/failed`;
|
||||||
export const PASSPORT_SUCCESS_REDIRECT_URL = `${DASHBOARD_URL}/store/calendar/auth/finish`;
|
export const PASSPORT_SUCCESS_REDIRECT_URL = `${DASHBOARD_URL}/store/calendar/auth/finish`;
|
||||||
export const ACCOUNT_EXPORT_URL = `${DASHBOARD_URL}/v1/user/profile/export/`;
|
export const ACCOUNT_EXPORT_URL = `${DASHBOARD_URL}/api/v1/user/profile/export/`;
|
||||||
export const TERMIN_PLANNER_URL = process.env.TERMIN_PLANNER_URL;
|
export const TERMIN_PLANNER_URL = process.env.TERMIN_PLANNER_URL;
|
||||||
|
|
||||||
export enum PAYMENT_PLAN {
|
export enum PAYMENT_PLAN {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import Session from "../models/session";
|
import Session from "../models/session";
|
||||||
import {
|
import {
|
||||||
|
ACCOUNT_EXPORT_ID_LENGTH,
|
||||||
DASHBOARD_URL,
|
DASHBOARD_URL,
|
||||||
DEFAULT_SESSION_EXPIRY,
|
DEFAULT_SESSION_EXPIRY,
|
||||||
HEADER_X_AUTHORIZATION,
|
HEADER_X_AUTHORIZATION,
|
||||||
|
@ -57,7 +58,7 @@ export function newFeedbackId() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newAccountExportId() {
|
export function newAccountExportId() {
|
||||||
return uuidv4();
|
return crypto.randomBytes(ACCOUNT_EXPORT_ID_LENGTH).toString("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserAgentOS(req: Request) {
|
export function getUserAgentOS(req: Request) {
|
||||||
|
|
Loading…
Reference in New Issue