parent
fa9ec31666
commit
aa54ffb4f8
@ -1,7 +1,15 @@
|
||||
{
|
||||
"name": "root",
|
||||
"name": "museo.red",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"lerna": "^3.22.1"
|
||||
},
|
||||
"scripts": {
|
||||
"bootstrap": "lerna bootstrap",
|
||||
"lint": "lerna run lint --stream",
|
||||
"fix": "lerna run fix --stream",
|
||||
"start": "lerna run start --stream",
|
||||
"build": "lerna run build --stream",
|
||||
"serve": "lerna run serve --stream"
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,3 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env"]
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["eslint:recommended", "prettier"],
|
||||
"plugins": ["prettier", "simple-import-sort"],
|
||||
"rules": {},
|
||||
"root": true,
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"env": {
|
||||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
"rules": {
|
||||
"prettier/prettier": ["error", {}, { "usePrettierrc": true }],
|
||||
"simple-import-sort/sort": "error"
|
||||
}
|
||||
}
|
@ -0,0 +1,200 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/node,vim,emacs,linux
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node,vim,emacs,linux
|
||||
|
||||
### Emacs ###
|
||||
# -*- mode: gitignore; -*-
|
||||
*~
|
||||
\#*\#
|
||||
/.emacs.desktop
|
||||
/.emacs.desktop.lock
|
||||
*.elc
|
||||
auto-save-list
|
||||
tramp
|
||||
.\#*
|
||||
|
||||
# Org-mode
|
||||
.org-id-locations
|
||||
*_archive
|
||||
|
||||
# flymake-mode
|
||||
*_flymake.*
|
||||
|
||||
# eshell files
|
||||
/eshell/history
|
||||
/eshell/lastdir
|
||||
|
||||
# elpa packages
|
||||
/elpa/
|
||||
|
||||
# reftex files
|
||||
*.rel
|
||||
|
||||
# AUCTeX auto folder
|
||||
/auto/
|
||||
|
||||
# cask packages
|
||||
.cask/
|
||||
dist/
|
||||
|
||||
# Flycheck
|
||||
flycheck_*.el
|
||||
|
||||
# projectiles files
|
||||
.projectile
|
||||
|
||||
# directory configuration
|
||||
.dir-locals.el
|
||||
|
||||
# network security
|
||||
/network-security.data
|
||||
|
||||
|
||||
### Linux ###
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
.env*.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
### Vim ###
|
||||
# Swap
|
||||
[._]*.s[a-v][a-z]
|
||||
!*.svg # comment out if you don't need vector files
|
||||
[._]*.sw[a-p]
|
||||
[._]s[a-rt-v][a-z]
|
||||
[._]ss[a-gi-z]
|
||||
[._]sw[a-p]
|
||||
|
||||
# Session
|
||||
Session.vim
|
||||
Sessionx.vim
|
||||
|
||||
# Temporary
|
||||
.netrwhist
|
||||
# Auto-generated tag files
|
||||
tags
|
||||
# Persistent undo
|
||||
[._]*.un~
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/node,vim,emacs,linux
|
||||
|
||||
.log/
|
||||
build/
|
@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": false,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 90,
|
||||
"tabWidth": 2
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
version: '3.1'
|
||||
|
||||
services:
|
||||
mongo:
|
||||
container_name: mongo
|
||||
image: mongo:4.2.10
|
||||
restart: always
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USER}
|
||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD}
|
||||
ports:
|
||||
- 27017:27017
|
||||
volumes:
|
||||
- ${DATA_HOME}:/data/db
|
||||
mongo-express:
|
||||
container_name: mongo-express
|
||||
image: mongo-express
|
||||
restart: always
|
||||
ports:
|
||||
- 8181:8081
|
||||
environment:
|
||||
ME_CONFIG_MONGODB_SERVER: 'mongo'
|
||||
ME_CONFIG_MONGODB_ADMINUSERNAME: ${MONGODB_USER}
|
||||
ME_CONFIG_MONGODB_ADMINPASSWORD: ${MONGODB_PASSWORD}
|
||||
|
||||
redis:
|
||||
container_name: redis
|
||||
image: redis:6.0.8
|
||||
restart: always
|
||||
ports:
|
||||
- 6379:6379
|
||||
command: >
|
||||
--requirepass ${REDIS_PASSWORD}
|
||||
volumes:
|
||||
- ${DATA_HOME}:/data
|
@ -0,0 +1,18 @@
|
||||
MONGODB_PASSWORD=
|
||||
MONGODB_USER=museum
|
||||
|
||||
REDIS_PASSWORD=
|
||||
|
||||
SESSION_SECRET=
|
||||
|
||||
FACEBOOK_CLIENT_ID=
|
||||
FACEBOOK_CALLBACK_URL=
|
||||
FACEBOOK_CLIENT_SECRET=
|
||||
|
||||
TWITTER_CONSUMER_KEY=
|
||||
TWITTER_CONSUMER_SECRET=
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
DATA_HOME=
|
@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"target": "es2020",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"noEmit": true,
|
||||
"checkJs": true,
|
||||
"lib": ["dom", "es2017"]
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.0.1",
|
||||
"description": "server for museo.red",
|
||||
"main": "index.js",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "babel src --out-dir build --copy-files",
|
||||
"serve": "node build/index.js",
|
||||
"start": "nodemon -w src --exec \"yarn build && yarn serve\"",
|
||||
"lint": "eslint src/**/*.js*",
|
||||
"fix": "eslint --fix src/**/*.js*"
|
||||
},
|
||||
"author": "Ian Mancini <ianmethyst@gmail.com>",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.12.1",
|
||||
"@babel/core": "^7.12.3",
|
||||
"@babel/preset-env": "^7.12.1",
|
||||
"@types/body-parser": "^1.19.0",
|
||||
"@types/connect-redis": "0.0.14",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/express": "^4.17.8",
|
||||
"@types/express-session": "^1.17.0",
|
||||
"@types/mongoose": "^5.7.36",
|
||||
"@types/node": "^14.14.3",
|
||||
"@types/passport": "^1.0.4",
|
||||
"@types/redis": "^2.8.28",
|
||||
"eslint": "^7.12.0",
|
||||
"eslint-config-prettier": "^6.14.0",
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"eslint-plugin-simple-import-sort": "^5.0.3",
|
||||
"nodemon": "^2.0.6",
|
||||
"prettier": "^2.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"body-parser": "^1.19.0",
|
||||
"connect-redis": "^5.0.0",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"express-session": "^1.17.1",
|
||||
"mongoose": "^5.10.10",
|
||||
"passport": "^0.4.1",
|
||||
"passport-facebook": "^3.0.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-twitter": "^1.0.4",
|
||||
"redis": "^3.0.2",
|
||||
"regenerator-runtime": "^0.13.7"
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
#!/bin/sh
|
||||
|
||||
check_available() {
|
||||
# Function to check if a program is installed
|
||||
which $1 &> /dev/null
|
||||
|
||||
if [ $? = 1 ]; then
|
||||
echo "$1 is not available, please install it before running the script"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
gen_password() {
|
||||
check_available openssl
|
||||
openssl rand -base64 32
|
||||
}
|
||||
|
||||
read -p "Redis password: (press enter to randomize): " redis_password
|
||||
redis_password=${redis_password:-`gen_password`}
|
||||
|
||||
read -p "Session secret: (press enter to randomize): " session_secret
|
||||
session_secret=${session_secret:-`gen_password`}
|
||||
|
||||
read -p "MongoDB password: (press enter to randomize): " mongo_password
|
||||
mongo_password=${mongo_password:-`gen_password`}
|
||||
|
||||
|
||||
if [ ! -f "./.env" ]; then
|
||||
echo "Copying ./env.example to ./.env"
|
||||
cp ./env.example ./.env
|
||||
fi
|
||||
|
||||
sed -i -e "s#REDIS_PASSWORD=.*#REDIS_PASSWORD=${redis_password}#g" \
|
||||
"$(dirname "$0")/.env"
|
||||
|
||||
sed -i -e "s#SESSION_SECRET=.*#SESSION_SECRET=${session_secret}#g" \
|
||||
"$(dirname "$0")/.env"
|
||||
|
||||
sed -i -e "s#MONGODB_PASSWORD=.*#MONGODB_PASSWORD=${mongo_password}#g" \
|
||||
"$(dirname "$0")/.env"
|
||||
|
||||
read -p "Start docker containers? (requires docker-compose) [Y/n] " start_docker
|
||||
start_docker=${start_docker:-Y}
|
||||
|
||||
if [[ $start_docker =~ [yY] ]]; then
|
||||
check_available docker-compose
|
||||
sudo docker-compose up -d
|
||||
fi
|
@ -0,0 +1,9 @@
|
||||
const ensureAuthenticated = (req, res, next) => {
|
||||
if (req.isAuthenticated()) {
|
||||
return next()
|
||||
} else {
|
||||
return res.redirect('/')
|
||||
}
|
||||
}
|
||||
|
||||
export default ensureAuthenticated
|
@ -0,0 +1,32 @@
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'path'
|
||||
dotenv.config({ path: path.join(__dirname, '../../../.env') })
|
||||
|
||||
import { Router } from 'express'
|
||||
import passport from 'passport'
|
||||
|
||||
import UserModel from '../models/user'
|
||||
import { facebookRouter, facebookStrategy } from './providers/facebook'
|
||||
import { googleRouter, googleStrategy } from './providers/google'
|
||||
import { twitterRouter, twitterStrategy } from './providers/twitter'
|
||||
|
||||
export function initPassport() {
|
||||
passport.serializeUser((user, done) => {
|
||||
done(null, user.id)
|
||||
})
|
||||
|
||||
passport.deserializeUser((id, done) => {
|
||||
UserModel.findById(id, (err, user) => {
|
||||
done(err, user)
|
||||
})
|
||||
})
|
||||
|
||||
passport.use('facebook', facebookStrategy())
|
||||
passport.use('twitter', twitterStrategy())
|
||||
passport.use('google', googleStrategy())
|
||||
}
|
||||
|
||||
export const authRouter = Router()
|
||||
authRouter.use(facebookRouter)
|
||||
authRouter.use(twitterRouter)
|
||||
authRouter.use(googleRouter)
|
@ -0,0 +1,31 @@
|
||||
import path from 'path'
|
||||
import dotenv from 'dotenv'
|
||||
dotenv.config({ path: path.join(__dirname, '../../../.env') });
|
||||
|
||||
import { Strategy } from 'passport-facebook';
|
||||
|
||||
import { Router } from 'express'
|
||||
import passport from 'passport'
|
||||
|
||||
import genericStrategy from '../strategy'
|
||||
|
||||
const strategyOptions = {
|
||||
clientID: process.env.FACEBOOK_CLIENT_ID,
|
||||
clientSecret: process.env.FACEBOOK_CLIENT_SECRET,
|
||||
callbackURL: '/auth/facebook/redirect',
|
||||
profileFields: ['id', 'displayName', 'picture.type(large)', 'email']
|
||||
}
|
||||
|
||||
export function facebookStrategy() {
|
||||
return new Strategy(strategyOptions, genericStrategy('facebook'));
|
||||
}
|
||||
|
||||
export const facebookRouter = Router()
|
||||
|
||||
const facebookAuthenticateOptions = {
|
||||
authType: 'rerequest', scope: ['email']
|
||||
}
|
||||
|
||||
facebookRouter.get('/facebook', passport.authenticate('facebook', facebookAuthenticateOptions));
|
||||
facebookRouter.get('/facebook/redirect',
|
||||
passport.authenticate('facebook', { successRedirect: '/museo', failureRedirect: '/' }));
|
@ -0,0 +1,26 @@
|
||||
import { Strategy } from 'passport-google-oauth20';
|
||||
|
||||
import { Router } from 'express'
|
||||
import passport from 'passport'
|
||||
|
||||
import genericStrategy from '../strategy'
|
||||
|
||||
const strategyOptions = {
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: '/auth/google/redirect',
|
||||
}
|
||||
|
||||
export function googleStrategy() {
|
||||
return new Strategy(strategyOptions, genericStrategy('google'));
|
||||
}
|
||||
|
||||
export const googleRouter = Router()
|
||||
|
||||
const googleAuthenticateOptions = {
|
||||
scope: ['email', 'profile']
|
||||
}
|
||||
|
||||
googleRouter.get('/google', passport.authenticate('google', googleAuthenticateOptions));
|
||||
googleRouter.get('/google/redirect',
|
||||
passport.authenticate('google', { successRedirect: '/museo', failureRedirect: '/' }));
|
@ -0,0 +1,23 @@
|
||||
import { Strategy } from 'passport-twitter';
|
||||
|
||||
import { Router } from 'express'
|
||||
import passport from 'passport'
|
||||
|
||||
import genericStrategy from '../strategy'
|
||||
|
||||
const strategyOptions = {
|
||||
consumerKey: process.env.TWITTER_CONSUMER_KEY,
|
||||
consumerSecret: process.env.TWITTER_CONSUMER_SECRET,
|
||||
callbackURL: '/auth/twitter/redirect',
|
||||
includeEmail: true,
|
||||
}
|
||||
|
||||
export function twitterStrategy() {
|
||||
return new Strategy(strategyOptions, genericStrategy('twitter'));
|
||||
}
|
||||
|
||||
export const twitterRouter = Router()
|
||||
|
||||
twitterRouter.get('/twitter', passport.authenticate('twitter'));
|
||||
twitterRouter.get('/twitter/redirect',
|
||||
passport.authenticate('twitter', { successRedirect: '/museo', failureRedirect: '/' }));
|
@ -0,0 +1,38 @@
|
||||
import UserModel from '../models/user'
|
||||
|
||||
function genericStrategy(provider) {
|
||||
return async (_accessToken, _refreshToken, profile, done) => {
|
||||
if (!profile.emails?.[0].value) {
|
||||
console.error(`${provider} Email permission not provided`)
|
||||
return done(null, false)
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await UserModel.findOne({ provider, providerId: profile.id }).exec()
|
||||
|
||||
console.info(`${provider} user found. Logging in.`)
|
||||
|
||||
if (user) {
|
||||
return done(null, user)
|
||||
}
|
||||
|
||||
console.info(`${provider} user not found. Creating.`)
|
||||
|
||||
const newUser = new UserModel({
|
||||
providerId: profile.id,
|
||||
name: profile.displayName,
|
||||
provider,
|
||||
email: profile.emails?.[0].value,
|
||||
photo: profile.photos?.[0].value.replace('_normal', '').replace('=s96-c', ''),
|
||||
}).save((err) => {
|
||||
if (err) throw err
|
||||
})
|
||||
|
||||
return done(null, newUser)
|
||||
} catch (e) {
|
||||
return done(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default genericStrategy
|
@ -0,0 +1,80 @@
|
||||
import 'regenerator-runtime/runtime';
|
||||
import path from 'path'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
import http from 'http';
|
||||
import express from 'express';
|
||||
import bodyParser from 'body-parser'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import session from 'express-session'
|
||||
import redis from 'redis'
|
||||
import connectRedis from 'connect-redis'
|
||||
|
||||
import passport from 'passport'
|
||||
import mongoose from 'mongoose'
|
||||
|
||||
import { authRouter, initPassport } from './auth/passport';
|
||||
import ensureAuthenticated from './auth/middleware';
|
||||
|
||||
const RedisStore = connectRedis(session);
|
||||
const redisClient = redis.createClient({
|
||||
host: '127.0.0.1',
|
||||
port: 6379,
|
||||
password: process.env.REDIS_PASSWORD
|
||||
})
|
||||
|
||||
redisClient.on('error', (error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
redisClient.on('connect', () => {
|
||||
console.log('Connection to Redis has been established successfully.');
|
||||
});
|
||||
|
||||
mongoose.connect('mongodb://localhost:27017', {
|
||||
useCreateIndex: true,
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true,
|
||||
user: process.env.MONGODB_USER,
|
||||
pass: process.env.MONGODB_PASSWORD,
|
||||
dbName: 'museum'
|
||||
}, () => {
|
||||
console.log('Connection to MongoDB has been established successfully.')
|
||||
})
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
const server = http.createServer(app);
|
||||
|
||||
app.use(bodyParser.json());
|
||||
app.use(bodyParser.urlencoded({ extended: false }));
|
||||
app.use(cookieParser(process.env.SESSION_SECRET));
|
||||
app.use(
|
||||
session({
|
||||
store: new RedisStore({ client: redisClient }),
|
||||
secret: process.env.SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
})
|
||||
);
|
||||
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
initPassport();
|
||||
app.use('/auth', authRouter);
|
||||
|
||||
app.use(express.static(path.resolve(__dirname, '../static')));
|
||||
|
||||
app.get('/', (_req, res) => {
|
||||
res.render('index');
|
||||
});
|
||||
|
||||
app.get('/museo', ensureAuthenticated, (req, res) => {
|
||||
res.render('museo', { user: req.user })
|
||||
});
|
||||
|
||||
server.listen(3000, () => {
|
||||
console.log('> Ready on http://localhost:3000');
|
||||
});
|
@ -0,0 +1,27 @@
|
||||
import { model, Schema } from 'mongoose'
|
||||
|
||||
const userSchema = new Schema(
|
||||
{
|
||||
providerId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
provider: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
photo: String,
|
||||
},
|
||||
{ timestamps: true },
|
||||
)
|
||||
|
||||
const UserModel = model('User', userSchema)
|
||||
export default UserModel
|
Loading…
Reference in new issue