vesta-web 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. vesta/__init__.py +486 -0
  2. vesta/db/UNIAUTH.sql +58 -0
  3. vesta/db/db_service.py +253 -0
  4. vesta/emptyProject/.gitignore +16 -0
  5. vesta/emptyProject/.gitlab-ci.yml +12 -0
  6. vesta/emptyProject/CONTRIBUTING.md +45 -0
  7. vesta/emptyProject/LICENSE.md +3 -0
  8. vesta/emptyProject/README.md +44 -0
  9. vesta/emptyProject/crons/exemple.py +13 -0
  10. vesta/emptyProject/db/schema.sql +0 -0
  11. vesta/emptyProject/install.sh +17 -0
  12. vesta/emptyProject/mailing/mailReset.html +1 -0
  13. vesta/emptyProject/mailing/mailVerif.html +1 -0
  14. vesta/emptyProject/misc/nginx_local +15 -0
  15. vesta/emptyProject/misc/nginx_prod +29 -0
  16. vesta/emptyProject/misc/nginx_prod_ws +29 -0
  17. vesta/emptyProject/misc/vesta.service +11 -0
  18. vesta/emptyProject/requirements.txt +0 -0
  19. vesta/emptyProject/server_static.ini +6 -0
  20. vesta/emptyProject/server_static.py +15 -0
  21. vesta/emptyProject/server_static_orm.ini +13 -0
  22. vesta/emptyProject/server_static_orm.py +15 -0
  23. vesta/emptyProject/server_vesta.ini +27 -0
  24. vesta/emptyProject/server_vesta.py +17 -0
  25. vesta/emptyProject/server_vesta_ws.ini +31 -0
  26. vesta/emptyProject/server_vesta_ws.py +56 -0
  27. vesta/emptyProject/static/home/auth.css +101 -0
  28. vesta/emptyProject/static/home/auth.html +38 -0
  29. vesta/emptyProject/static/home/auth.js +159 -0
  30. vesta/emptyProject/static/home/reset.html +33 -0
  31. vesta/emptyProject/static/home/verif.html +33 -0
  32. vesta/emptyProject/static/main.html +14 -0
  33. vesta/emptyProject/static/main.mjs +9 -0
  34. vesta/emptyProject/static/mobileUiManifest.mjs +3 -0
  35. vesta/emptyProject/static/style.css +0 -0
  36. vesta/emptyProject/static/translations/en.mjs +2 -0
  37. vesta/emptyProject/static/translations/fr.mjs +2 -0
  38. vesta/emptyProject/static/translations/translation.mjs +51 -0
  39. vesta/emptyProject/static/ws/onMessage.mjs +21 -0
  40. vesta/emptyProject/tests/example/foo.py +7 -0
  41. vesta/http/baseServer.py +257 -0
  42. vesta/http/error.py +5 -0
  43. vesta/http/redirect.py +5 -0
  44. vesta/http/response.py +85 -0
  45. vesta/mailing/mailing_service.py +126 -0
  46. vesta/scripts/initDB.py +52 -0
  47. vesta/scripts/install.py +76 -0
  48. vesta/scripts/testsRun.py +83 -0
  49. vesta/scripts/utils.py +84 -0
  50. vesta/scripts/vesta.py +225 -0
  51. vesta_web-1.1.0.dist-info/METADATA +55 -0
  52. vesta_web-1.1.0.dist-info/RECORD +55 -0
  53. vesta_web-1.1.0.dist-info/WHEEL +4 -0
  54. vesta_web-1.1.0.dist-info/entry_points.txt +2 -0
  55. vesta_web-1.1.0.dist-info/licenses/LICENSE.md +5 -0
vesta/__init__.py ADDED
@@ -0,0 +1,486 @@
1
+ """
2
+ ------------------------------------------------------------------------------------------------------------------------
3
+
4
+ __.--~~.,-.__ Welcome to VESTA
5
+ `~-._.-(`-.__`-.
6
+ \ `~~` Vesta is a strongly opinionated and minimalist Framework.
7
+ .--./ \ It bundles a lot of different features:
8
+ /# \ \.--. - a http server
9
+ \ / /# \ - a websocket server
10
+ '--' \ / - a mailing server
11
+ '--' - some tooling
12
+ - an HTML templating library
13
+ - a reactive frontend library
14
+ - a unique authentification system called uniauth (think google account)
15
+
16
+ ________________________________________________________________________________________________________________________
17
+ """
18
+
19
+ # stdlibs
20
+ import datetime
21
+ import json
22
+ import inspect
23
+ import asyncio
24
+ import threading
25
+ import os
26
+ import time
27
+
28
+ import fastwsgi
29
+ import bcrypt
30
+ import jwt
31
+ from configparser import ConfigParser
32
+ import websockets
33
+
34
+ # in house modules
35
+ from vesta.db import db_service as db
36
+ from vesta.mailing import mailing_service as mailing
37
+
38
+ # OTP imports
39
+ import random as rand
40
+ import math
41
+
42
+ from vesta.http import baseServer as server
43
+ from vesta.http import error as HTTPError
44
+ from vesta.http import redirect as HTTPRedirect
45
+ from vesta.http import response as Response
46
+ server = server.BaseServer
47
+ Response = Response.Response
48
+ HTTPRedirect = HTTPRedirect.HTTPRedirect
49
+ HTTPError = HTTPError.HTTPError
50
+ from colorama import Fore, Style
51
+ from colorama import init as colorama_init
52
+ colorama_init()
53
+
54
+ class Server(server):
55
+ features = {}
56
+
57
+ def __init__(self, path, configFile, noStart=False):
58
+ print(Fore.GREEN,"[INFO] starting Vesta server...")
59
+ self.path = path
60
+
61
+ self.importConf(configFile)
62
+ self.db = db.DB(user=self.config.get('DB', 'DB_USER'), password=self.config.get('DB', 'DB_PASSWORD'),
63
+ host=self.config.get('DB', 'DB_HOST'), port=int(self.config.get('DB', 'DB_PORT')),
64
+ db=self.config.get('DB', 'DB_NAME'))
65
+ print(Fore.GREEN,"[INFO] successfully connected to postgresql!")
66
+ self.uniauth = db.DB(user=self.config.get('DB', 'DB_USER'), password=self.config.get('DB', 'DB_PASSWORD'),
67
+ host=self.config.get('UNIAUTH', 'DB_HOST'),
68
+ port=int(self.config.get('UNIAUTH', 'DB_PORT')), db=self.config.get('UNIAUTH', 'DB_NAME'))
69
+ print(Fore.GREEN,"[INFO] successfully connected to uniauth!")
70
+
71
+ if not self.config.getboolean("server", "DEBUG"):
72
+ self.noreply = mailing.Mailing(self.config.get('MAILING', 'MAILING_HOST'),
73
+ self.config.get('MAILING', 'MAILING_PORT'),
74
+ "noreply@carbonlab.dev", self.config.get('MAILING', 'NOREPLY_PASSWORD'),
75
+ self.config.get("server", "SERVICE_NAME"), self.path)
76
+ print(Fore.GREEN,"[INFO] successfully connected to the mailing service!")
77
+
78
+ if noStart:
79
+ return
80
+
81
+ self.start()
82
+
83
+ #-----------------------UNIAUTH RELATED METHODS-----------------------------
84
+
85
+ @server.expose
86
+ def auth(self, parrain=None, ref=None):
87
+ return (open(self.path + "/static/home/auth.html").read() )
88
+
89
+ @server.expose
90
+ def reset(self, email):
91
+ return open(self.path + "/static/home/reset.html").read()
92
+
93
+ @server.expose
94
+ def verif(self, ref=None):
95
+ self.checkJwt(verif=True)
96
+ return open(self.path + "/static/home/verif.html").read()
97
+
98
+ @server.expose
99
+ def resendVerif(self):
100
+ user = self.getUser()
101
+ self.sendVerification(user)
102
+
103
+ @server.expose
104
+ def login(self, email, password, parrain=None):
105
+ if 'email' and 'password':
106
+ password = password.encode('utf-8') # converting to bytes array
107
+ account = self.uniauth.getUserCredentials(email)
108
+ # If account exists in accounts table
109
+ if account:
110
+ msg = self.connect(account, password)
111
+ else:
112
+ msg = self.register(email, password, parrain)
113
+ else:
114
+ msg = 'please give an email and a password'
115
+
116
+ return msg
117
+
118
+ @server.expose
119
+ def changePasswordVerif(self,mail, code, password):
120
+ id = self.uniauth.getSomething("account", mail,"email")["id"]
121
+ if not id :
122
+ return "no account found for " + mail
123
+
124
+ password = password.encode('utf-8')
125
+ actual = self.uniauth.getSomething("reset_code", id)
126
+ if actual and str(actual["code"]) == code and actual["expiration"] > datetime.datetime.now():
127
+ self.changePassword(id, password)
128
+ return "ok"
129
+ else:
130
+ return "Code erroné"
131
+
132
+ @server.expose
133
+ def passwordReset(self, email):
134
+ account = self.uniauth.getUserCredentials(email)
135
+ # If account exists in accounts table
136
+ if account:
137
+ OTP = self.generateOTP(12)
138
+ expiration = datetime.datetime.now() + datetime.timedelta(hours=1)
139
+ self.uniauth.insertReplaceDict("reset_code", {"id": account["id"], "code": OTP, "expiration": expiration})
140
+ if not self.config.getboolean("server", "DEBUG"):
141
+ self.noreply.sendTemplate('mailReset.html', email, "Reset your password","Your reset code: "+OTP, OTP)
142
+ else:
143
+ print("RESET OTP : ", OTP)
144
+ return "ok"
145
+ else:
146
+ return "no account found for " + email
147
+
148
+
149
+ @server.expose
150
+ def signup(self, code):
151
+ user = self.getUser()
152
+ actual = self.uniauth.getSomething("verif_code", user)
153
+ if str(actual["code"]) == code and actual["expiration"] > datetime.datetime.now():
154
+ self.uniauth.edit("account", user, "verified", True)
155
+ self.createJwt(user, True)
156
+ self.onLogin(user)
157
+ return "ok"
158
+ else:
159
+ return "Code erroné"
160
+
161
+ @server.expose
162
+ def logout(self):
163
+ token = self.getJWT()
164
+ self.response.del_cookie('JWT')
165
+ self.response.del_cookie('auth')
166
+ raise HTTPRedirect(self.response, "/auth")
167
+
168
+ @server.expose
169
+ def goodbye(self): #delete account
170
+ token = self.getJWT()
171
+ info = jwt.decode(token, self.config.get('security', 'SECRET_KEY'), algorithms=['HS256'])
172
+ self.response.del_cookie('JWT')
173
+ self.response.del_cookie('auth')
174
+ self.uniauth.deleteSomething("account", info['username'])
175
+ self.uniauth.deleteSomething("verif_code", info['username'])
176
+ return 'ok'
177
+
178
+ def createJwt(self, uid, verified):
179
+ payload = {
180
+ 'username': uid,
181
+ 'verified': verified,
182
+ 'exp': datetime.datetime.utcnow() + datetime.timedelta(days=1)
183
+ }
184
+ token = jwt.encode(payload, self.config.get('security', 'SECRET_KEY'), algorithm='HS256')
185
+
186
+ self.response.set_cookie('JWT', token, exp={"value": 100, "unit": "days"}, httponly=True, samesite='Strict',
187
+ secure=True)
188
+ self.response.set_cookie('auth', "true", exp={"value": 100, "unit": "days"}, samesite='Strict')
189
+
190
+ return token
191
+
192
+ def checkJwt(self, verif=False):
193
+ try:
194
+ token = self.getJWT()
195
+ except:
196
+ raise HTTPRedirect(self.response, "/auth")
197
+
198
+ try:
199
+ info = jwt.decode(token, self.config.get('security', 'SECRET_KEY'), algorithms=['HS256'])
200
+ if not info['verified'] and not verif:
201
+ raise HTTPRedirect(self.response, "/verif")
202
+ elif verif and info['verified']:
203
+ raise HTTPRedirect(self.response, self.config.get("server", "DEFAULT_ENDPOINT"))
204
+ except (jwt.ExpiredSignatureError, jwt.DecodeError):
205
+ self.logout()
206
+ return info
207
+
208
+ def getJWT(self):
209
+ token = self.response.cookies['JWT']
210
+ return token
211
+
212
+ def getUser(self):
213
+ info = self.checkJwt()
214
+ return info['username']
215
+
216
+ def sendVerification(self, uid, mail=''):
217
+ if mail == '':
218
+ mail = self.uniauth.getUser(uid, target="email")["email"]
219
+ OTP = self.generateOTP()
220
+ expiration = datetime.datetime.now() + datetime.timedelta(hours=1)
221
+ self.uniauth.insertReplaceDict("verif_code", {"id": uid, "code": OTP, "expiration": expiration})
222
+ if not self.config.getboolean("server", "DEBUG"):
223
+ self.noreply.sendConfirmation(mail, OTP)
224
+ else:
225
+ print("OTP : ", OTP)
226
+
227
+ def generateOTP(self,n=6):
228
+ digits = "0123456789"
229
+ OTP = ""
230
+
231
+ for i in range(n):
232
+ OTP += digits[math.floor(rand.random() * 10)]
233
+
234
+ return OTP
235
+
236
+ def register(self, username, password, parrain):
237
+ salt = bcrypt.gensalt()
238
+ hash = bcrypt.hashpw(password, salt)
239
+ uid = self.uniauth.createAccount(username, hash, parrain)
240
+ self.createJwt(uid, False)
241
+ self.sendVerification(uid=uid, mail=username)
242
+ pending = self.uniauth.getSomething('pendingmembership', username, 'email')
243
+ if pending:
244
+ self.uniauth.insertDict('membership', {'account': uid, 'company': pending['company']})
245
+ self.uniauth.deleteSomething('pendingmembership', pending['id'])
246
+ return "verif"
247
+
248
+ def changePassword(self, uid, password):
249
+ salt = bcrypt.gensalt()
250
+ hash = bcrypt.hashpw(password, salt)
251
+ self.uniauth.edit("account",uid, 'password', hash)
252
+
253
+ def connect(self, account, password):
254
+ result = bcrypt.checkpw(password, account["password"])
255
+ if result:
256
+ self.createJwt(account['id'], account["verified"])
257
+ self.onLogin(account['id'])
258
+ return "ok"
259
+ else:
260
+ return "invalid email or password"
261
+
262
+ def onLogin(self, uid):
263
+ pass
264
+
265
+ def onStart(self):
266
+ pass
267
+
268
+ #--------------------------GENERAL USE METHODS------------------------------
269
+
270
+ def parseAcceptLanguage(self, acceptLanguage):
271
+ languages = acceptLanguage.split(",")
272
+ locale_q_pairs = []
273
+
274
+ for language in languages:
275
+ if language.split(";")[0] == language:
276
+ # no q => q = 1
277
+ locale_q_pairs.append((language.strip(), "1"))
278
+ else:
279
+ locale = language.split(";")[0].strip()
280
+ q = language.split(";")[1].split("=")[1]
281
+ locale_q_pairs.append((locale, q))
282
+
283
+ return locale_q_pairs
284
+
285
+ def importConf(self, configFile):
286
+ self.config = ConfigParser()
287
+ try:
288
+ self.config.read(self.path + configFile)
289
+ print(Fore.GREEN,"[INFO] Vesta - config at " + self.path + configFile + " loaded")
290
+ except Exception:
291
+ print(Fore.RED,"[ERROR] Vesta - Please create a config file")
292
+
293
+ def start(self):
294
+ self.fileCache = {}
295
+
296
+ if self.features.get("websockets"):
297
+ self.id = 1 # TODO give a different id to each server to allow them to contact eachother
298
+ self.pool = {}
299
+ self.waiting_clients = {}
300
+ self.currentWaiting = 0
301
+ self.stop_event = asyncio.Event()
302
+ websocket_thread = threading.Thread(target=self.startWebSockets)
303
+ websocket_thread.start()
304
+ print(Fore.GREEN,"[INFO] Vesta - WS server started")
305
+
306
+ if self.features.get("errors"):
307
+ for code, page in self.features["errors"].items():
308
+ Response.ERROR_PAGES[code] = self.path + page
309
+
310
+ self.onStart()
311
+
312
+ fastwsgi.server.nowait = 1
313
+ fastwsgi.server.hook_sigint = 1
314
+
315
+ print(Fore.GREEN,"[INFO] Vesta - server running on PID:", os.getpid())
316
+ fastwsgi.server.init(app=self.onrequest, host=self.config.get('server', 'IP'),
317
+ port=int(self.config.get('server', 'PORT')))
318
+ while True:
319
+ code = fastwsgi.server.run()
320
+ if code != 0:
321
+ break
322
+ time.sleep(0)
323
+ self.close()
324
+
325
+ def close(self):
326
+ print(Fore.GREEN,"[INFO] SIGTERM/SIGINT received")
327
+
328
+ fastwsgi.server.close()
329
+ if self.features.get("websockets"):
330
+ self.closeWebSockets()
331
+
332
+ self.clean()
333
+ print(Fore.GREEN,"[INFO] SERVER STOPPED")
334
+ exit()
335
+
336
+ def startWebSockets(self):
337
+ server = threading.Thread(target=self.runWebsockets, daemon=True)
338
+ server.start()
339
+
340
+ async def handle_message(self,websocket):
341
+ pass
342
+
343
+ def runWebsockets(self):
344
+ """
345
+ Asynchronously runs the WebSocket server.
346
+ """
347
+ try:
348
+ loop = asyncio.new_event_loop()
349
+ asyncio.set_event_loop(loop)
350
+
351
+ async def _run_server():
352
+ async with websockets.serve(
353
+ self.handle_message,
354
+ self.config.get("server", "IP"),
355
+ int(self.config.get("NOTIFICATION", "PORT"))
356
+ ):
357
+ await asyncio.Future()
358
+
359
+ loop.run_until_complete(_run_server())
360
+ loop.run_forever()
361
+ loop.close()
362
+
363
+ except Exception as e:
364
+ print(Fore.RED,"[ERROR] Vesta - exception in ws server:", e)
365
+
366
+ def file(self,path):
367
+ file = self.fileCache.get(path)
368
+ if file:
369
+ return file
370
+ else:
371
+ file = open(path)
372
+ content = file.read()
373
+ file.close()
374
+ self.fileCache[path] = content
375
+ return content
376
+
377
+ def closeWebSockets(self):
378
+ for client, ws in self.pool.items():
379
+ # Close the websocket connection
380
+ asyncio.run_coroutine_threadsafe(ws.close(), asyncio.get_event_loop())
381
+ self.stop_event.set()
382
+ print(Fore.GREEN,"[INFO] WS server closed")
383
+
384
+ def clean(self):
385
+ pass
386
+
387
+ def stop(self):
388
+ fastwsgi.server.close()
389
+ for client, ws in self.pool.items():
390
+ self.db.deleteSomething("active_client", client)
391
+ print(Fore.GREEN,"[INFO] cleaned database")
392
+
393
+ # --------------------------------WEBSOCKETS--------------------------------
394
+ @server.expose
395
+ def config(self):
396
+ if not self.features.get("websockets"):
397
+ raise HTTPError(self.response, 404)
398
+
399
+ if self.config.getboolean("server", "DEBUG"):
400
+ url = self.config.get("NOTIFICATION", "DEBUG_URL")
401
+ else:
402
+ url = self.config.get("NOTIFICATION", "URL")
403
+
404
+ doc= f"""const WEBSOCKETS = "{url}";\n """ + "export {WEBSOCKETS}"
405
+
406
+ self.response.type = "application/javascript"
407
+ self.response.headers = [('Content-Type', 'application/javascript')]
408
+ return doc
409
+
410
+
411
+ @server.expose
412
+ def authWS(self, connectionId):
413
+ account_id = self.getUser()
414
+ if int(self.waiting_clients[int(connectionId)]["uid"]) != account_id:
415
+ raise HTTPError(self.response, 403)
416
+
417
+ connection = self.db.insertDict("active_client", {"userid": account_id, "server": self.id}, True)
418
+ self.pool[connection] = self.waiting_clients[int(connectionId)]["connection"]
419
+ del self.waiting_clients[int(connectionId)]
420
+ self.onWSAuth(account_id)
421
+ return str(connection)
422
+
423
+ def onWSAuth(self,uid):
424
+ pass
425
+
426
+ async def sendNotificationAsync(self, account, content, exclude=None):
427
+ message = {"type": "notif", "content": content}
428
+
429
+ clients = self.db.getAll("active_client", account, "userid")
430
+ clients_to_remove = []
431
+
432
+ for client in clients:
433
+ #TODO handle multi server
434
+ if self.pool.get(client["id"]):
435
+ websocket = self.pool[client["id"]]
436
+
437
+ if exclude and websocket == exclude:
438
+ continue
439
+
440
+ try:
441
+ if hasattr(websocket, 'closed') and websocket.closed:
442
+ print(f"[INFO] Client {client['id']} already closed (closed)")
443
+ clients_to_remove.append(client["id"])
444
+ continue
445
+ elif hasattr(websocket, 'state') and websocket.state.name in ['CLOSED', 'CLOSING']:
446
+ print(f"[INFO] Client {client['id']} already closed (state: {websocket.state.name})")
447
+ clients_to_remove.append(client["id"])
448
+ continue
449
+
450
+ await websocket.send(json.dumps(message))
451
+ except Exception as e:
452
+ clients_to_remove.append(client["id"])
453
+ else:
454
+ clients_to_remove.append(client["id"])
455
+
456
+ for client_id in clients_to_remove:
457
+ print(f"[INFO] Cleaning client {client_id}")
458
+ if client_id in self.pool:
459
+ del self.pool[client_id]
460
+ self.db.deleteSomething("active_client", client_id)
461
+
462
+
463
+ def checkWSAuth(self, ws, clientID):
464
+ if self.pool.get(clientID) == ws:
465
+ return True
466
+ return False
467
+
468
+ def sendNotification(self, account, content):
469
+ message = {"type": "notif", "content": content}
470
+
471
+ async def ws_send(message):
472
+ await websocket.send(message)
473
+
474
+ clients = self.db.getAll("active_client", account, "userid")
475
+ for client in clients:
476
+ #TODO handle multi server
477
+ if self.pool.get(client["id"]):
478
+ websocket = self.pool[client["id"]]
479
+ try:
480
+ asyncio.run(ws_send(json.dumps(message)))
481
+ except Exception as e:
482
+ print(Fore.RED,"[ERROR] Vesta - exception sending a message '", message,"' on a ws", e)
483
+ del self.pool[client["id"]]
484
+ self.db.deleteSomething("active_client", client["id"])
485
+ else:
486
+ self.db.deleteSomething("active_client", client["id"])
vesta/db/UNIAUTH.sql ADDED
@@ -0,0 +1,58 @@
1
+ create table account (
2
+ id bigserial NOT NULL PRIMARY KEY,
3
+ email varchar(319),
4
+ password bytea not null,
5
+ inscription date,
6
+ verified boolean,
7
+ parrain int
8
+ );
9
+ ALTER TABLE account
10
+ ADD CONSTRAINT PARRAIN_CONSTRAINT
11
+ FOREIGN KEY (parrain)
12
+ REFERENCES account (id)
13
+ ON UPDATE CASCADE;
14
+
15
+ create table verif_code (
16
+ id integer NOT NULL PRIMARY KEY,
17
+ code varchar(6) NOT NULL,
18
+ expiration timestamp NOT NULL
19
+ );
20
+
21
+ create table if not exists reset_code (
22
+ id integer NOT NULL PRIMARY KEY,
23
+ code varchar(12) NOT NULL,
24
+ expiration timestamp NOT NULL
25
+ );
26
+
27
+ create table companies (
28
+ id bigserial NOT NULL PRIMARY KEY,
29
+ name varchar(22) NOT NULL
30
+ );
31
+
32
+ create table membership (
33
+ id bigserial NOT NULL PRIMARY KEY,
34
+ account integer NOT NULL,
35
+ company integer NOT NULL
36
+ );
37
+
38
+ ALTER TABLE membership
39
+ ADD CONSTRAINT MEM_USER_CONSTRAINT FOREIGN KEY (account) REFERENCES account (id) ON UPDATE CASCADE;
40
+ ALTER TABLE membership
41
+ ADD CONSTRAINT MEM_COMPANY_CONSTRAINT FOREIGN KEY (company) REFERENCES companies (id) ON UPDATE CASCADE;
42
+
43
+ create table pendingMembership (
44
+ id bigserial NOT NULL PRIMARY KEY,
45
+ email varchar(319),
46
+ company integer NOT NULL
47
+ );
48
+ ALTER TABLE pendingMembership
49
+ ADD CONSTRAINT PEND_MEM_COMPANY_CONSTRAINT FOREIGN KEY (company) REFERENCES companies (id) ON UPDATE CASCADE;
50
+
51
+
52
+ create table if not exists unibridge(
53
+ id bigserial NOT NULL PRIMARY KEY,
54
+ source varchar(64) NOT NULL,
55
+ name varchar(256) NOT NULL,
56
+ related_table varchar(256),
57
+ value jsonb DEFAULT '{}'
58
+ )