wip(backend): Initial work on people
This commit is contained in:
parent
75841ff0a2
commit
d1f9528e70
11 changed files with 532 additions and 21 deletions
|
@ -82,11 +82,13 @@ model Pattern {
|
|||
model Person {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
data String?
|
||||
name String @default("")
|
||||
notes String @default("")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
measies String @default("{}")
|
||||
Pattern Pattern[]
|
||||
public Boolean @default(false)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import chalk from 'chalk'
|
|||
// Load environment variables
|
||||
import dotenv from 'dotenv'
|
||||
import { asJson } from './utils/index.mjs'
|
||||
import { measurements } from '../../../config/measurements.mjs'
|
||||
dotenv.config()
|
||||
|
||||
// Allow these 2 to be imported
|
||||
|
@ -55,6 +56,7 @@ const config = {
|
|||
base: 'user',
|
||||
},
|
||||
languages: ['en', 'de', 'es', 'fr', 'nl'],
|
||||
measies: measurements,
|
||||
aws: {
|
||||
ses: {
|
||||
region: process.env.BACKEND_AWS_SES_REGION || 'us-east-1',
|
||||
|
@ -197,7 +199,7 @@ if (envToBool(process.env.BACKEND_ENABLE_TESTS)) {
|
|||
* which is not a given since there's a number of environment
|
||||
* variables that need to be set for this backend to function.
|
||||
*/
|
||||
export function verifyConfig() {
|
||||
export function verifyConfig(silent = false) {
|
||||
const emptyString = (input) => {
|
||||
if (typeof input === 'string' && input.length > 0) return false
|
||||
return true
|
||||
|
@ -219,8 +221,8 @@ export function verifyConfig() {
|
|||
}
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
for (const o of ok) console.log(o)
|
||||
|
||||
for (const e of errors) {
|
||||
console.log(
|
||||
chalk.redBright('Error:'),
|
||||
|
@ -233,6 +235,7 @@ export function verifyConfig() {
|
|||
'\n'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log(chalk.redBright('Invalid configuration. Stopping here...'))
|
||||
|
|
48
sites/backend/src/controllers/person.mjs
Normal file
48
sites/backend/src/controllers/person.mjs
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { PersonModel } from '../models/person.mjs'
|
||||
|
||||
export function PersonController() {}
|
||||
|
||||
/*
|
||||
* Create a person for the authenticated user
|
||||
*
|
||||
* See: https://freesewing.dev/reference/backend/api
|
||||
*/
|
||||
PersonController.prototype.create = async (req, res, tools) => {
|
||||
const Person = new PersonModel(tools)
|
||||
await Person.create(req)
|
||||
|
||||
return Person.sendResponse(res)
|
||||
}
|
||||
|
||||
/*
|
||||
* Read a person
|
||||
*
|
||||
* See: https://freesewing.dev/reference/backend/api
|
||||
*/
|
||||
PersonController.prototype.read = async (req, res, tools) => {
|
||||
//const Person = new PersonModel(tools)
|
||||
//await Person.read({ id: req.params.id })
|
||||
//return Person.sendResponse(res)
|
||||
}
|
||||
|
||||
/*
|
||||
* Update a person
|
||||
*
|
||||
* See: https://freesewing.dev/reference/backend/api
|
||||
*/
|
||||
PersonController.prototype.update = async (req, res, tools) => {
|
||||
//const Person = new PersonModel(tools)
|
||||
//await Person.update(req)
|
||||
//return Person.sendResponse(res)
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove a person
|
||||
*
|
||||
* See: https://freesewing.dev/reference/backend/api
|
||||
*/
|
||||
PersonController.prototype.delete = async (req, res, tools) => {
|
||||
//const Person = new PersonModel(tools)
|
||||
//await Person.remove(req)
|
||||
//return Person.sendResponse(res)
|
||||
}
|
|
@ -4,6 +4,15 @@ import http from 'passport-http'
|
|||
import jwt from 'passport-jwt'
|
||||
import { ApikeyModel } from './models/apikey.mjs'
|
||||
|
||||
const levelFromRole = (role) => {
|
||||
if (role === 'user') return 4
|
||||
if (role === 'bughunter') return 5
|
||||
if (role === 'support') return 6
|
||||
if (role === 'admin') return 8
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function loadExpressMiddleware(app) {
|
||||
// FIXME: Is this still needed in FreeSewing v3?
|
||||
//app.use(bodyParser.urlencoded({ extended: true }))
|
||||
|
@ -27,7 +36,11 @@ function loadPassportMiddleware(passport, tools) {
|
|||
...tools.config.jwt,
|
||||
},
|
||||
(jwt_payload, done) => {
|
||||
return done(null, { ...jwt_payload, uid: jwt_payload._id })
|
||||
return done(null, {
|
||||
...jwt_payload,
|
||||
uid: jwt_payload._id,
|
||||
level: levelFromRole(jwt_payload.role),
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
336
sites/backend/src/models/person.mjs
Normal file
336
sites/backend/src/models/person.mjs
Normal file
|
@ -0,0 +1,336 @@
|
|||
import { log } from '../utils/log.mjs'
|
||||
import { setPersonAvatar } from '../utils/sanity.mjs'
|
||||
|
||||
export function PersonModel(tools) {
|
||||
this.config = tools.config
|
||||
this.prisma = tools.prisma
|
||||
this.decrypt = tools.decrypt
|
||||
this.encrypt = tools.encrypt
|
||||
this.clear = {} // For holding decrypted data
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
PersonModel.prototype.create = async function ({ body, user }) {
|
||||
if (user.level < 3) return this.setResponse(403, 'insufficientAccessLevel')
|
||||
if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing')
|
||||
if (!body.name || typeof body.name !== 'string') return this.setResponse(400, 'nameMissing')
|
||||
|
||||
// Prepare data
|
||||
const data = { name: body.name }
|
||||
if (body.notes || typeof body.notes === 'string') data.notes = body.notes
|
||||
if (body.public === true) data.public = true
|
||||
if (body.measies) data.measies = this.encrypt(this.sanitizeMeasurements(body.measies))
|
||||
data.userId = user.uid
|
||||
|
||||
// Create record
|
||||
try {
|
||||
this.record = await this.prisma.person.create({ data })
|
||||
} catch (err) {
|
||||
log.warn(err, 'Could not create person')
|
||||
return this.setResponse(500, 'createPersonFailed')
|
||||
}
|
||||
|
||||
return this.setResponse(201, 'created', {
|
||||
person: {
|
||||
...this.record,
|
||||
measies: this.decrypt(this.record.measies),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* Loads a person from the database based on the where clause you pass it
|
||||
*
|
||||
* Stores result in this.record
|
||||
*/
|
||||
PersonModel.prototype.read = async function (where) {
|
||||
try {
|
||||
this.record = await this.prisma.person.findUnique({ where })
|
||||
} catch (err) {
|
||||
log.warn({ err, where }, 'Could not read person')
|
||||
}
|
||||
|
||||
this.reveal()
|
||||
|
||||
return this.setExists()
|
||||
}
|
||||
|
||||
/*
|
||||
* Helper method to decrypt at-rest data
|
||||
*/
|
||||
//PersonModel.prototype.reveal = async function (where) {
|
||||
// this.clear = {}
|
||||
// if (this.record) {
|
||||
// this.clear.bio = this.decrypt(this.record.bio)
|
||||
// this.clear.github = this.decrypt(this.record.github)
|
||||
// this.clear.email = this.decrypt(this.record.email)
|
||||
// this.clear.initial = this.decrypt(this.record.initial)
|
||||
// }
|
||||
//
|
||||
// return this
|
||||
//}
|
||||
|
||||
/*
|
||||
* Helper method to encrypt at-rest data
|
||||
*/
|
||||
//UserModel.prototype.cloak = function (data) {
|
||||
// for (const field of ['bio', 'github', 'email']) {
|
||||
// if (typeof data[field] !== 'undefined') data[field] = this.encrypt(data[field])
|
||||
// }
|
||||
// if (typeof data.password === 'string') data.password = asJson(hashPassword(data.password))
|
||||
//
|
||||
// return data
|
||||
//}
|
||||
|
||||
/*
|
||||
* Loads a user from the database based on the where clause you pass it
|
||||
* In addition prepares it for returning the account data
|
||||
*
|
||||
* Stores result in this.record
|
||||
*/
|
||||
//UserModel.prototype.readAsAccount = async function (where) {
|
||||
// await this.read(where)
|
||||
//
|
||||
// return this.setResponse(200, false, {
|
||||
// result: 'success',
|
||||
// account: this.asAccount(),
|
||||
// })
|
||||
//}
|
||||
|
||||
/*
|
||||
* Finds a user based on one of the accepted unique fields which are:
|
||||
* - lusername (lowercase username)
|
||||
* - ehash
|
||||
* - id
|
||||
*
|
||||
* Stores result in this.record
|
||||
*/
|
||||
//UserModel.prototype.find = async function (body) {
|
||||
// try {
|
||||
// this.record = await this.prisma.user.findFirst({
|
||||
// where: {
|
||||
// OR: [
|
||||
// { lusername: { equals: clean(body.username) } },
|
||||
// { ehash: { equals: hash(clean(body.username)) } },
|
||||
// { id: { equals: parseInt(body.username) || -1 } },
|
||||
// ],
|
||||
// },
|
||||
// })
|
||||
// } catch (err) {
|
||||
// log.warn({ err, body }, `Error while trying to find user: ${body.username}`)
|
||||
// }
|
||||
//
|
||||
// this.reveal()
|
||||
//
|
||||
// return this.setExists()
|
||||
//}
|
||||
|
||||
/*
|
||||
* Checks this.record and sets a boolean to indicate whether
|
||||
* the user exists or not
|
||||
*
|
||||
* Stores result in this.exists
|
||||
*/
|
||||
PersonModel.prototype.setExists = function () {
|
||||
this.exists = this.record ? true : false
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/*
|
||||
* Updates the user data - Used when we create the data ourselves
|
||||
* so we know it's safe
|
||||
*/
|
||||
//UserModel.prototype.safeUpdate = async function (data) {
|
||||
// try {
|
||||
// this.record = await this.prisma.user.update({
|
||||
// where: { id: this.record.id },
|
||||
// data,
|
||||
// })
|
||||
// } catch (err) {
|
||||
// log.warn(err, 'Could not update user record')
|
||||
// process.exit()
|
||||
// return this.setResponse(500, 'updateUserFailed')
|
||||
// }
|
||||
// await this.reveal()
|
||||
//
|
||||
// return this.setResponse(200)
|
||||
//}
|
||||
|
||||
/*
|
||||
* Updates the user data - Used when we pass through user-provided data
|
||||
* so we can't be certain it's safe
|
||||
*/
|
||||
//UserModel.prototype.unsafeUpdate = async function (body) {
|
||||
// const data = {}
|
||||
// const notes = []
|
||||
// // Bio
|
||||
// if (typeof body.bio === 'string') data.bio = body.bio
|
||||
// // Consent
|
||||
// if ([0, 1, 2, 3].includes(body.consent)) data.consent = body.consent
|
||||
// // Github
|
||||
// if (typeof body.github === 'string') data.github = body.github.split('@').pop()
|
||||
// // Imperial
|
||||
// if ([true, false].includes(body.imperial)) data.imperial = body.imperial
|
||||
// // Language
|
||||
// if (this.config.languages.includes(body.language)) data.language = body.language
|
||||
// // Newsletter
|
||||
// if ([true, false].includes(body.newsletter)) data.newsletter = body.newsletter
|
||||
// // Password
|
||||
// if (typeof body.password === 'string') data.password = body.password // Will be cloaked below
|
||||
// // Username
|
||||
// if (typeof body.username === 'string') {
|
||||
// const available = await this.isLusernameAvailable(body.username)
|
||||
// if (available) {
|
||||
// data.username = body.username.trim()
|
||||
// data.lusername = clean(body.username)
|
||||
// } else {
|
||||
// log.info(`Rejected user name change from ${data.username} to ${body.username.trim()}`)
|
||||
// notes.push('usernameChangeRejected')
|
||||
// }
|
||||
// }
|
||||
// // Image (img)
|
||||
// if (typeof body.img === 'string') {
|
||||
// const img = await setPersonAvatar(this.record.id, body.img)
|
||||
// data.img = img.url
|
||||
// }
|
||||
//
|
||||
// // Now update the record
|
||||
// await this.safeUpdate(this.cloak(data))
|
||||
//
|
||||
// const isUnitTest = this.isUnitTest(body)
|
||||
// if (typeof body.email === 'string' && this.clear.email !== clean(body.email)) {
|
||||
// // Email change (requires confirmation)
|
||||
// this.confirmation = await this.Confirmation.create({
|
||||
// type: 'emailchange',
|
||||
// data: {
|
||||
// language: this.record.language,
|
||||
// email: {
|
||||
// current: this.clear.email,
|
||||
// new: body.email,
|
||||
// },
|
||||
// },
|
||||
// userId: this.record.id,
|
||||
// })
|
||||
// if (!isUnitTest || this.config.tests.sendEmail) {
|
||||
// // Send confirmation email
|
||||
// await this.mailer.send({
|
||||
// template: 'emailchange',
|
||||
// language: this.record.language,
|
||||
// to: body.email,
|
||||
// cc: this.clear.email,
|
||||
// replacements: {
|
||||
// actionUrl: i18nUrl(this.language, `/confirm/emailchange/${this.Confirmation.record.id}`),
|
||||
// whyUrl: i18nUrl(this.language, `/docs/faq/email/why-emailchange`),
|
||||
// supportUrl: i18nUrl(this.language, `/patrons/join`),
|
||||
// },
|
||||
// })
|
||||
// }
|
||||
// } else if (typeof body.confirmation === 'string' && body.confirm === 'emailchange') {
|
||||
// // Handle email change confirmation
|
||||
// await this.Confirmation.read({ id: body.confirmation })
|
||||
//
|
||||
// if (!this.Confirmation.exists) {
|
||||
// log.warn(err, `Could not find confirmation id ${params.id}`)
|
||||
// return this.setResponse(404, 'failedToFindConfirmationId')
|
||||
// }
|
||||
//
|
||||
// if (this.Confirmation.record.type !== 'emailchange') {
|
||||
// log.warn(err, `Confirmation mismatch; ${params.id} is not an emailchange id`)
|
||||
// return this.setResponse(404, 'confirmationIdTypeMismatch')
|
||||
// }
|
||||
//
|
||||
// const data = this.Confirmation.clear.data
|
||||
// if (data.email.current === this.clear.email && typeof data.email.new === 'string') {
|
||||
// await this.safeUpdate({
|
||||
// email: this.encrypt(data.email.new),
|
||||
// ehash: hash(clean(data.email.new)),
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// const returnData = {
|
||||
// result: 'success',
|
||||
// account: this.asAccount(),
|
||||
// }
|
||||
// if (isUnitTest) returnData.confirmation = this.Confirmation.record.id
|
||||
//
|
||||
// return this.setResponse(200, false, returnData)
|
||||
//}
|
||||
|
||||
/*
|
||||
* Returns record data
|
||||
*/
|
||||
PersonModel.prototype.asPerson = function () {
|
||||
return this.reveal()
|
||||
}
|
||||
|
||||
/*
|
||||
* Helper method to set the response code, result, and body
|
||||
*
|
||||
* Will be used by this.sendResponse()
|
||||
*/
|
||||
PersonModel.prototype.setResponse = function (status = 200, error = false, data = {}) {
|
||||
this.response = {
|
||||
status,
|
||||
body: {
|
||||
result: 'success',
|
||||
...data,
|
||||
},
|
||||
}
|
||||
if (status > 201) {
|
||||
this.response.body.error = error
|
||||
this.response.body.result = 'error'
|
||||
this.error = true
|
||||
} else this.error = false
|
||||
|
||||
return this.setExists()
|
||||
}
|
||||
|
||||
/*
|
||||
* Helper method to send response
|
||||
*/
|
||||
PersonModel.prototype.sendResponse = async function (res) {
|
||||
return res.status(this.response.status).send(this.response.body)
|
||||
}
|
||||
|
||||
/*
|
||||
* Update method to determine whether this request is
|
||||
* part of a unit test
|
||||
*/
|
||||
//UserModel.prototype.isUnitTest = function (body) {
|
||||
// if (!body.unittest) return false
|
||||
// if (!this.clear.email.split('@').pop() === this.config.tests.domain) return false
|
||||
// if (body.email && !body.email.split('@').pop() === this.config.tests.domain) return false
|
||||
//
|
||||
// return true
|
||||
//}
|
||||
|
||||
/*
|
||||
* Helper method to check an account is ok
|
||||
*/
|
||||
//UserModel.prototype.isOk = function () {
|
||||
// if (
|
||||
// this.exists &&
|
||||
// this.record &&
|
||||
// this.record.status > 0 &&
|
||||
// this.record.consent > 0 &&
|
||||
// this.record.role &&
|
||||
// this.record.role !== 'blocked'
|
||||
// )
|
||||
// return true
|
||||
//
|
||||
// return false
|
||||
//}
|
||||
|
||||
/* Helper method to parse user-supplied measurements */
|
||||
PersonModel.prototype.sanitizeMeasurements = function (input) {
|
||||
const measies = {}
|
||||
if (typeof input !== 'object') return measies
|
||||
for (const [m, val] of Object.entries(input)) {
|
||||
if (this.config.measies.includes(m) && typeof val === 'number' && val > 0) measies[m] = val
|
||||
}
|
||||
|
||||
return measies
|
||||
}
|
|
@ -367,7 +367,6 @@ UserModel.prototype.unsafeUpdate = async function (body) {
|
|||
},
|
||||
userId: this.record.id,
|
||||
})
|
||||
console.log(this.config.tests)
|
||||
if (!isUnitTest || this.config.tests.sendEmail) {
|
||||
// Send confirmation email
|
||||
await this.mailer.send({
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { apikeyRoutes } from './apikey.mjs'
|
||||
import { userRoutes } from './user.mjs'
|
||||
import { personRoutes } from './person.mjs'
|
||||
|
||||
export const routes = {
|
||||
apikeyRoutes,
|
||||
userRoutes,
|
||||
personRoutes,
|
||||
}
|
||||
|
|
41
sites/backend/src/routes/person.mjs
Normal file
41
sites/backend/src/routes/person.mjs
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { PersonController } from '../controllers/person.mjs'
|
||||
|
||||
const Person = new PersonController()
|
||||
const jwt = ['jwt', { session: false }]
|
||||
const bsc = ['basic', { session: false }]
|
||||
|
||||
export function personRoutes(tools) {
|
||||
const { app, passport } = tools
|
||||
|
||||
// Create person
|
||||
app.post('/people/jwt', passport.authenticate(...jwt), (req, res) =>
|
||||
Person.create(req, res, tools)
|
||||
)
|
||||
app.post('/people/key', passport.authenticate(...bsc), (req, res) =>
|
||||
Person.create(req, res, tools)
|
||||
)
|
||||
|
||||
// Read person
|
||||
app.get('/people/:handle/jwt', passport.authenticate(...jwt), (req, res) =>
|
||||
Person.read(req, res, tools)
|
||||
)
|
||||
app.get('/people/:handle/jwt', passport.authenticate(...bsc), (req, res) =>
|
||||
Person.read(req, res, tools)
|
||||
)
|
||||
|
||||
// Update person
|
||||
app.put('/people/:handle/jwt', passport.authenticate(...jwt), (req, res) =>
|
||||
Person.update(req, res, tools)
|
||||
)
|
||||
app.put('/people/:handle/key', passport.authenticate(...bsc), (req, res) =>
|
||||
Person.update(req, res, tools)
|
||||
)
|
||||
|
||||
// Delete person
|
||||
app.delete('/people/:handle/jwt', passport.authenticate(...jwt), (req, res) =>
|
||||
Person.delete(req, res, tools)
|
||||
)
|
||||
app.delete('/people/:handle/key', passport.authenticate(...bsc), (req, res) =>
|
||||
Person.delete(req, res, tools)
|
||||
)
|
||||
}
|
|
@ -1,12 +1,14 @@
|
|||
import { userTests } from './user.mjs'
|
||||
import { accountTests } from './account.mjs'
|
||||
import { apikeyTests } from './apikey.mjs'
|
||||
import { personTests } from './person.mjs'
|
||||
import { setup } from './shared.mjs'
|
||||
|
||||
const runTests = async (...params) => {
|
||||
await userTests(...params)
|
||||
await apikeyTests(...params)
|
||||
await accountTests(...params)
|
||||
//await userTests(...params)
|
||||
//await apikeyTests(...params)
|
||||
//await accountTests(...params)
|
||||
await personTests(...params)
|
||||
}
|
||||
|
||||
// Load initial data required for tests
|
||||
|
|
64
sites/backend/tests/person.mjs
Normal file
64
sites/backend/tests/person.mjs
Normal file
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
name String @default("")
|
||||
notes String @default("")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
measies String @default("{}")
|
||||
Pattern Pattern[]
|
||||
public Boolean @default(false)
|
||||
*/
|
||||
|
||||
export const personTests = async (chai, config, expect, store) => {
|
||||
const data = {
|
||||
jwt: {
|
||||
name: 'Joost',
|
||||
notes: 'These are them notes',
|
||||
measies: {
|
||||
chest: 1000,
|
||||
neck: 420,
|
||||
},
|
||||
public: true,
|
||||
},
|
||||
key: {
|
||||
name: 'Sorcha',
|
||||
notes: 'These are also notes',
|
||||
measies: {
|
||||
chest: 930,
|
||||
neck: 360,
|
||||
},
|
||||
public: false,
|
||||
},
|
||||
}
|
||||
|
||||
for (const auth of ['jwt', 'key']) {
|
||||
describe(`${store.icon('person', auth)} Person tests (${auth})`, () => {
|
||||
it(`${store.icon('person', auth)} Should create a new person (${auth})`, (done) => {
|
||||
chai
|
||||
.request(config.api)
|
||||
.post(`/people/${auth}`)
|
||||
.set(
|
||||
'Authorization',
|
||||
auth === 'jwt'
|
||||
? 'Bearer ' + store.account.token
|
||||
: 'Basic ' +
|
||||
new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString(
|
||||
'base64'
|
||||
)
|
||||
)
|
||||
.send(data[auth])
|
||||
.end((err, res) => {
|
||||
console.log(res.body)
|
||||
expect(err === null).to.equal(true)
|
||||
expect(res.status).to.equal(201)
|
||||
expect(res.body.result).to.equal(`success`)
|
||||
for (const [key, val] of Object.entries(data[auth])) {
|
||||
expect(res.body.person[key]).to.equal(val)
|
||||
}
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
|
@ -7,7 +7,7 @@ import { randomString } from '../src/utils/crypto.mjs'
|
|||
|
||||
dotenv.config()
|
||||
|
||||
const config = verifyConfig()
|
||||
const config = verifyConfig(true)
|
||||
const expect = chai.expect
|
||||
chai.use(http)
|
||||
|
||||
|
@ -26,6 +26,7 @@ export const setup = async () => {
|
|||
user: '🧑 ',
|
||||
jwt: '🎫 ',
|
||||
key: '🎟️ ',
|
||||
person: '🧕 ',
|
||||
},
|
||||
randomString,
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue