rlz-engine 0.0.1
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.
- package/dist/back/auth/controllers.d.ts +7 -0
- package/dist/back/auth/controllers.js +39 -0
- package/dist/back/auth/model.d.ts +14 -0
- package/dist/back/auth/model.js +1 -0
- package/dist/back/auth/storage.d.ts +21 -0
- package/dist/back/auth/storage.js +91 -0
- package/dist/back/auth/utils.d.ts +8 -0
- package/dist/back/auth/utils.js +80 -0
- package/dist/back/config.d.ts +1 -0
- package/dist/back/config.js +1 -0
- package/dist/back/logger.d.ts +3 -0
- package/dist/back/logger.js +36 -0
- package/dist/back/server.d.ts +11 -0
- package/dist/back/server.js +75 -0
- package/dist/back/storage/db.d.ts +14 -0
- package/dist/back/storage/db.js +43 -0
- package/dist/back/storage/model.d.ts +33 -0
- package/dist/back/storage/model.js +7 -0
- package/dist/back/storage/sync.d.ts +5 -0
- package/dist/back/storage/sync.js +16 -0
- package/dist/client/api/api.d.ts +9 -0
- package/dist/client/api/api.js +57 -0
- package/dist/client/api/auth.d.ts +5 -0
- package/dist/client/api/auth.js +21 -0
- package/dist/client/screens/404.d.ts +2 -0
- package/dist/client/screens/404.js +10 -0
- package/dist/client/screens/SignupSigninScreen.d.ts +6 -0
- package/dist/client/screens/SignupSigninScreen.js +129 -0
- package/dist/client/state/auth.d.ts +12 -0
- package/dist/client/state/auth.js +74 -0
- package/dist/client/sync.d.ts +13 -0
- package/dist/client/sync.js +37 -0
- package/dist/shared/api/auth.d.ts +43 -0
- package/dist/shared/api/auth.js +16 -0
- package/dist/shared/api/sync.d.ts +43 -0
- package/dist/shared/api/sync.js +21 -0
- package/dist/shared/utils/datetime.d.ts +3 -0
- package/dist/shared/utils/datetime.js +15 -0
- package/package.json +65 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { FastifyInstance, RawServerBase } from 'fastify';
|
|
2
|
+
import { AuthStorage } from './storage';
|
|
3
|
+
interface AuthEndpointsOpts {
|
|
4
|
+
storage: AuthStorage;
|
|
5
|
+
}
|
|
6
|
+
export declare const AUTH_API: <T extends RawServerBase>(app: FastifyInstance<T>, { storage }: AuthEndpointsOpts) => Promise<void>;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { httpErrors } from '@fastify/sensible';
|
|
2
|
+
import fastifyPlugin from 'fastify-plugin';
|
|
3
|
+
import zodToJsonSchema from 'zod-to-json-schema';
|
|
4
|
+
import { API_AUTH_RESPONSE_SCHEMA_V0, API_SIGNIN_REQUEST_SCHEMA_V0, API_SIGNUP_REQUEST_SCHEMA_V0 } from '../../shared/api/auth';
|
|
5
|
+
import { logout, signin, signup } from './utils';
|
|
6
|
+
export const AUTH_API = fastifyPlugin(async function authApi(app, { storage }) {
|
|
7
|
+
app.post('/api/v0/signup', {
|
|
8
|
+
schema: {
|
|
9
|
+
body: zodToJsonSchema(API_SIGNUP_REQUEST_SCHEMA_V0),
|
|
10
|
+
response: { 200: zodToJsonSchema(API_AUTH_RESPONSE_SCHEMA_V0) }
|
|
11
|
+
}
|
|
12
|
+
}, async (req, _resp) => {
|
|
13
|
+
const body = API_SIGNUP_REQUEST_SCHEMA_V0.parse(req.body);
|
|
14
|
+
return await signup(storage, body.name, body.email, body.password);
|
|
15
|
+
});
|
|
16
|
+
app.post('/api/v0/signin', {
|
|
17
|
+
schema: {
|
|
18
|
+
body: zodToJsonSchema(API_SIGNIN_REQUEST_SCHEMA_V0),
|
|
19
|
+
response: { 200: zodToJsonSchema(API_AUTH_RESPONSE_SCHEMA_V0) }
|
|
20
|
+
}
|
|
21
|
+
}, async (req, _resp) => {
|
|
22
|
+
const body = API_SIGNIN_REQUEST_SCHEMA_V0.parse(req.body);
|
|
23
|
+
const r = await signin(storage, body.name, body.password);
|
|
24
|
+
if (r === null) {
|
|
25
|
+
return httpErrors.unauthorized();
|
|
26
|
+
}
|
|
27
|
+
return r;
|
|
28
|
+
});
|
|
29
|
+
app.post('/api/v0/logout', {}, async (req, resp) => {
|
|
30
|
+
await storage.auth(req.headers);
|
|
31
|
+
const authHeader = req.headers.authorization;
|
|
32
|
+
if (authHeader === undefined) {
|
|
33
|
+
throw httpErrors.forbidden();
|
|
34
|
+
}
|
|
35
|
+
const [userId, tempPassword] = authHeader.split(':');
|
|
36
|
+
await logout(storage, userId, tempPassword);
|
|
37
|
+
return resp.code(204).send();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Binary } from 'mongodb';
|
|
2
|
+
export interface StorageUser {
|
|
3
|
+
_id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
email: string;
|
|
6
|
+
passwordSalt: Binary;
|
|
7
|
+
passwordHash: Binary;
|
|
8
|
+
lastActivityDate: Date;
|
|
9
|
+
}
|
|
10
|
+
export interface StorageTempPassword {
|
|
11
|
+
userId: string;
|
|
12
|
+
passwordHash: Binary;
|
|
13
|
+
validUntil: Date;
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { FastifyRequest } from 'fastify';
|
|
2
|
+
import { Binary, Collection } from 'mongodb';
|
|
3
|
+
import { MongoStorage } from '../storage/db';
|
|
4
|
+
import { StorageTempPassword, StorageUser } from './model';
|
|
5
|
+
export declare class AuthStorage {
|
|
6
|
+
private readonly logger;
|
|
7
|
+
private readonly mongo;
|
|
8
|
+
constructor(mongo: MongoStorage);
|
|
9
|
+
init(): Promise<void>;
|
|
10
|
+
createUser(id: string, name: string, email: string, passwordSalt: Binary, passwordHash: Binary): Promise<void>;
|
|
11
|
+
getUser(name: string): Promise<StorageUser | null>;
|
|
12
|
+
markUserActive(userId: string): Promise<void>;
|
|
13
|
+
pushTempPassword(userId: string, passwordHash: Binary, validUntil: Date): Promise<void>;
|
|
14
|
+
deleteTempPassword(userId: string, passwordHash: Binary): Promise<void>;
|
|
15
|
+
getUserByTempPassword(userId: string, passwordHash: Binary): Promise<StorageUser | null>;
|
|
16
|
+
private createCollections;
|
|
17
|
+
private createIndexes;
|
|
18
|
+
get users(): Collection<StorageUser>;
|
|
19
|
+
get tempPasswords(): Collection<StorageTempPassword>;
|
|
20
|
+
get auth(): (headers: FastifyRequest["headers"]) => Promise<string>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
import { logger } from '../logger';
|
|
3
|
+
import { auth } from './utils';
|
|
4
|
+
export class AuthStorage {
|
|
5
|
+
logger = logger('AuthStorage');
|
|
6
|
+
mongo;
|
|
7
|
+
constructor(mongo) {
|
|
8
|
+
this.mongo = mongo;
|
|
9
|
+
}
|
|
10
|
+
async init() {
|
|
11
|
+
await Promise.all([
|
|
12
|
+
'users', 'temp-passwords'
|
|
13
|
+
].map(i => this.mongo.createCollection(i)));
|
|
14
|
+
await this.createIndexes();
|
|
15
|
+
}
|
|
16
|
+
async createUser(id, name, email, passwordSalt, passwordHash) {
|
|
17
|
+
await this.users.insertOne({
|
|
18
|
+
_id: id,
|
|
19
|
+
name,
|
|
20
|
+
email,
|
|
21
|
+
passwordSalt,
|
|
22
|
+
passwordHash,
|
|
23
|
+
lastActivityDate: DateTime.utc().toJSDate()
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
async getUser(name) {
|
|
27
|
+
return await this.users.findOne({ name });
|
|
28
|
+
}
|
|
29
|
+
async markUserActive(userId) {
|
|
30
|
+
await this.users.updateOne({ _id: userId }, { $set: { lastActivityDate: DateTime.utc().toJSDate() } });
|
|
31
|
+
}
|
|
32
|
+
async pushTempPassword(userId, passwordHash, validUntil) {
|
|
33
|
+
await this.tempPasswords.insertOne({
|
|
34
|
+
userId,
|
|
35
|
+
passwordHash,
|
|
36
|
+
validUntil
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
async deleteTempPassword(userId, passwordHash) {
|
|
40
|
+
await this.tempPasswords.deleteOne({ userId, passwordHash });
|
|
41
|
+
}
|
|
42
|
+
async getUserByTempPassword(userId, passwordHash) {
|
|
43
|
+
const t = await this.tempPasswords.findOne({ userId, passwordHash });
|
|
44
|
+
if (t === null) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (DateTime.utc() > DateTime.fromJSDate(t.validUntil)) {
|
|
48
|
+
await this.tempPasswords.deleteOne({ _id: t._id });
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return await this.users.findOne({ _id: t.userId });
|
|
52
|
+
}
|
|
53
|
+
async createCollections() {
|
|
54
|
+
}
|
|
55
|
+
async createIndexes() {
|
|
56
|
+
await this.mongo.createIndexes(this.users, [
|
|
57
|
+
{
|
|
58
|
+
name: 'name_v0',
|
|
59
|
+
key: {
|
|
60
|
+
name: 1
|
|
61
|
+
},
|
|
62
|
+
unique: true
|
|
63
|
+
}
|
|
64
|
+
]);
|
|
65
|
+
await this.mongo.createIndexes(this.tempPasswords, [
|
|
66
|
+
{
|
|
67
|
+
name: 'userId_v0',
|
|
68
|
+
key: {
|
|
69
|
+
userId: 1
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'ttl_v0',
|
|
74
|
+
key: {
|
|
75
|
+
passwordHash: 1
|
|
76
|
+
},
|
|
77
|
+
// Temp passwords also have expirity dates, make sure they are syncronized with this expirity index
|
|
78
|
+
expireAfterSeconds: 60 * 60 * 24 * 7 // 7 days
|
|
79
|
+
}
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
get users() {
|
|
83
|
+
return this.mongo.db.collection('users');
|
|
84
|
+
}
|
|
85
|
+
get tempPasswords() {
|
|
86
|
+
return this.mongo.db.collection('temp-passwords');
|
|
87
|
+
}
|
|
88
|
+
get auth() {
|
|
89
|
+
return (headers) => auth(headers, this);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { FastifyRequest } from 'fastify';
|
|
2
|
+
import { ApiAuthResponseV0 } from '../../shared/api/auth';
|
|
3
|
+
import { AuthStorage } from './storage';
|
|
4
|
+
export declare function signup(storage: AuthStorage, name: string, email: string, password: string): Promise<ApiAuthResponseV0>;
|
|
5
|
+
export declare function signin(storage: AuthStorage, name: string, password: string): Promise<ApiAuthResponseV0 | null>;
|
|
6
|
+
export declare function logout(storage: AuthStorage, userId: string, tempPassword: string): Promise<void>;
|
|
7
|
+
export declare function verifyTempPassword(storage: AuthStorage, userId: string, tempPassword: string): Promise<string>;
|
|
8
|
+
export declare function auth(headers: FastifyRequest['headers'], storage: AuthStorage): Promise<string>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { httpErrors } from '@fastify/sensible';
|
|
2
|
+
import { randomBytes, scryptSync } from 'crypto';
|
|
3
|
+
import { DateTime } from 'luxon';
|
|
4
|
+
import { Binary, MongoServerError } from 'mongodb';
|
|
5
|
+
import { uuidv7 } from 'uuidv7';
|
|
6
|
+
const TEMP_PASSWORD_SALT = Buffer.from('cashmony-temp-password-salt', 'utf8');
|
|
7
|
+
export async function signup(storage, name, email, password) {
|
|
8
|
+
const salt = randomBytes(64);
|
|
9
|
+
const hash = calcHash(password, salt);
|
|
10
|
+
const id = uuidv7();
|
|
11
|
+
try {
|
|
12
|
+
await storage.createUser(id, name, email, new Binary(salt), new Binary(hash));
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
if (e instanceof MongoServerError && e.code === 11000) {
|
|
16
|
+
// duplicate key error
|
|
17
|
+
throw httpErrors.conflict();
|
|
18
|
+
}
|
|
19
|
+
throw e;
|
|
20
|
+
}
|
|
21
|
+
const tempPassword = await makeTempPassword(storage, id);
|
|
22
|
+
return {
|
|
23
|
+
id,
|
|
24
|
+
name,
|
|
25
|
+
email,
|
|
26
|
+
tempPassword
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export async function signin(storage, name, password) {
|
|
30
|
+
const u = await storage.getUser(name);
|
|
31
|
+
if (u === null) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const hash = calcHash(password, u.passwordSalt.value());
|
|
35
|
+
if (!hash.equals(u.passwordHash.value())) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const tempPassword = await makeTempPassword(storage, u._id);
|
|
39
|
+
await storage.markUserActive(u._id);
|
|
40
|
+
return {
|
|
41
|
+
id: u._id,
|
|
42
|
+
name: u.name,
|
|
43
|
+
email: u.email,
|
|
44
|
+
tempPassword
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export async function logout(storage, userId, tempPassword) {
|
|
48
|
+
const hash = calcHash(tempPassword, TEMP_PASSWORD_SALT);
|
|
49
|
+
await storage.deleteTempPassword(userId, new Binary(hash));
|
|
50
|
+
}
|
|
51
|
+
export async function verifyTempPassword(storage, userId, tempPassword) {
|
|
52
|
+
const hash = calcTempPasswordHash(Buffer.from(tempPassword, 'base64'));
|
|
53
|
+
const u = await storage.getUserByTempPassword(userId, new Binary(hash));
|
|
54
|
+
if (u === null) {
|
|
55
|
+
throw httpErrors.forbidden();
|
|
56
|
+
}
|
|
57
|
+
await storage.markUserActive(u._id);
|
|
58
|
+
return u._id;
|
|
59
|
+
}
|
|
60
|
+
async function makeTempPassword(storage, userId) {
|
|
61
|
+
const password = randomBytes(128);
|
|
62
|
+
const passwordHash = calcTempPasswordHash(password);
|
|
63
|
+
// Temp passwords also are removed from Mongo by TTL index, make sure expirity dates are syncronised
|
|
64
|
+
await storage.pushTempPassword(userId, new Binary(passwordHash), DateTime.utc().plus({ days: 7 }).toJSDate());
|
|
65
|
+
return password.toString('base64');
|
|
66
|
+
}
|
|
67
|
+
export async function auth(headers, storage) {
|
|
68
|
+
const authHeader = headers.authorization;
|
|
69
|
+
if (authHeader === undefined) {
|
|
70
|
+
throw httpErrors.forbidden();
|
|
71
|
+
}
|
|
72
|
+
const [userId, tempPassword] = authHeader.split(':');
|
|
73
|
+
return await verifyTempPassword(storage, userId, tempPassword);
|
|
74
|
+
}
|
|
75
|
+
function calcHash(password, salt) {
|
|
76
|
+
return scryptSync(password, salt, 512, { N: 1024 });
|
|
77
|
+
}
|
|
78
|
+
function calcTempPasswordHash(tempPassword) {
|
|
79
|
+
return scryptSync(tempPassword, TEMP_PASSWORD_SALT, 512, { N: 1024 });
|
|
80
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const PRODUCTION: boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const PRODUCTION = process.env.NODE_ENV === 'production';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { pino } from 'pino';
|
|
2
|
+
import { PRODUCTION } from './config';
|
|
3
|
+
export function logger(name) {
|
|
4
|
+
return pino(loggerOptions(name));
|
|
5
|
+
}
|
|
6
|
+
export function loggerOptions(name) {
|
|
7
|
+
if (PRODUCTION) {
|
|
8
|
+
return {
|
|
9
|
+
name,
|
|
10
|
+
transport: {
|
|
11
|
+
targets: [
|
|
12
|
+
{
|
|
13
|
+
level: 'info',
|
|
14
|
+
target: 'pino/file',
|
|
15
|
+
options: { destination: 1 }
|
|
16
|
+
}
|
|
17
|
+
// {
|
|
18
|
+
// level: 'info',
|
|
19
|
+
// target: 'pino-'
|
|
20
|
+
// }
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
name,
|
|
27
|
+
transport: {
|
|
28
|
+
targets: [
|
|
29
|
+
{
|
|
30
|
+
level: 'trace',
|
|
31
|
+
target: 'pino-pretty'
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { FastifyInstance, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerBase } from 'fastify';
|
|
2
|
+
import { Logger } from 'pino';
|
|
3
|
+
export type InitServerFuncType = <S extends RawServerBase>(server: FastifyInstance<S, RawRequestDefaultExpression<S>, RawReplyDefaultExpression<S>, Logger>) => Promise<void>;
|
|
4
|
+
export interface RunServerParams {
|
|
5
|
+
production: boolean;
|
|
6
|
+
domain: string;
|
|
7
|
+
certDir: string;
|
|
8
|
+
staticDir: string;
|
|
9
|
+
init: InitServerFuncType;
|
|
10
|
+
}
|
|
11
|
+
export declare function runServer({ production, domain, certDir, staticDir, init }: RunServerParams): Promise<void>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fastifyCompress from '@fastify/compress';
|
|
2
|
+
import fastifyCors from '@fastify/cors';
|
|
3
|
+
import fastifyResponseValidation from '@fastify/response-validation';
|
|
4
|
+
import fastifySensible, { httpErrors } from '@fastify/sensible';
|
|
5
|
+
import fastifyStatic from '@fastify/static';
|
|
6
|
+
import formatsPlugin from 'ajv-formats';
|
|
7
|
+
import fastify from 'fastify';
|
|
8
|
+
import { fastifyAcmeSecurePlugin, fastifyAcmeUnsecurePlugin, getCertAndKey } from 'fastify-acme';
|
|
9
|
+
import { createReadStream } from 'fs';
|
|
10
|
+
import { installIntoGlobal } from 'iterator-helpers-polyfill';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { logger } from './logger';
|
|
13
|
+
installIntoGlobal();
|
|
14
|
+
export async function runServer({ production, domain, certDir, staticDir, init }) {
|
|
15
|
+
const httpServer = fastify({
|
|
16
|
+
loggerInstance: logger('http'),
|
|
17
|
+
ajv: { plugins: [formatsPlugin] }
|
|
18
|
+
});
|
|
19
|
+
if (!production) {
|
|
20
|
+
await httpServer.register(fastifyCors, {
|
|
21
|
+
methods: ['get', 'post']
|
|
22
|
+
});
|
|
23
|
+
await httpServer.register(fastifyCompress);
|
|
24
|
+
await httpServer.register(fastifySensible);
|
|
25
|
+
await httpServer.register(fastifyResponseValidation, {
|
|
26
|
+
ajv: { plugins: [formatsPlugin] }
|
|
27
|
+
});
|
|
28
|
+
await init(httpServer);
|
|
29
|
+
httpServer.all('/api/*', async () => {
|
|
30
|
+
return httpErrors.notFound();
|
|
31
|
+
});
|
|
32
|
+
addStaticEndpoints(httpServer, staticDir);
|
|
33
|
+
await httpServer.listen({ host: 'localhost', port: 8080 });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
httpServer.register(fastifyAcmeUnsecurePlugin, { redirectDomain: domain });
|
|
37
|
+
await httpServer.listen({ port: 80 });
|
|
38
|
+
const certAndKey = await getCertAndKey(certDir, domain);
|
|
39
|
+
const httpsServer = fastify({
|
|
40
|
+
http2: true,
|
|
41
|
+
https: {
|
|
42
|
+
allowHTTP1: true,
|
|
43
|
+
cert: certAndKey.cert,
|
|
44
|
+
key: certAndKey.pkey
|
|
45
|
+
},
|
|
46
|
+
loggerInstance: logger('https'),
|
|
47
|
+
ajv: { plugins: [formatsPlugin] }
|
|
48
|
+
});
|
|
49
|
+
await httpsServer.register(fastifyAcmeSecurePlugin, {
|
|
50
|
+
certDir,
|
|
51
|
+
domain
|
|
52
|
+
});
|
|
53
|
+
await httpsServer.register(fastifyCors, {
|
|
54
|
+
methods: ['get', 'post']
|
|
55
|
+
});
|
|
56
|
+
await httpsServer.register(fastifyCompress);
|
|
57
|
+
await httpsServer.register(fastifySensible);
|
|
58
|
+
await httpsServer.register(fastifyResponseValidation, { ajv: { plugins: [formatsPlugin] } });
|
|
59
|
+
await init(httpsServer);
|
|
60
|
+
httpsServer.all('/api/*', async () => {
|
|
61
|
+
return httpErrors.notFound();
|
|
62
|
+
});
|
|
63
|
+
addStaticEndpoints(httpsServer, staticDir);
|
|
64
|
+
}
|
|
65
|
+
function addStaticEndpoints(server, staticPath) {
|
|
66
|
+
const absStaticPath = path.resolve(staticPath);
|
|
67
|
+
const absIndexPath = path.join(absStaticPath, 'index.html');
|
|
68
|
+
server.register((s) => {
|
|
69
|
+
s.register(fastifyStatic, { root: absStaticPath });
|
|
70
|
+
s.setNotFoundHandler(async (_req, resp) => {
|
|
71
|
+
await resp.type('text/html')
|
|
72
|
+
.send(createReadStream(absIndexPath));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Collection, Db, Document, IndexDescription } from 'mongodb';
|
|
2
|
+
type StorageIndexDescription = IndexDescription & {
|
|
3
|
+
name: string;
|
|
4
|
+
};
|
|
5
|
+
export declare class MongoStorage {
|
|
6
|
+
private readonly logger;
|
|
7
|
+
private readonly client;
|
|
8
|
+
constructor();
|
|
9
|
+
get db(): Db;
|
|
10
|
+
createCollection(name: string): Promise<void>;
|
|
11
|
+
createIndexes<T extends Document>(collection: Collection<T>, indexes: StorageIndexDescription[]): Promise<void>;
|
|
12
|
+
private listIndexes;
|
|
13
|
+
}
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { MongoClient } from 'mongodb';
|
|
2
|
+
import { logger } from '../logger';
|
|
3
|
+
export class MongoStorage {
|
|
4
|
+
logger;
|
|
5
|
+
client;
|
|
6
|
+
constructor() {
|
|
7
|
+
this.logger = logger('MongoStorage');
|
|
8
|
+
this.client = new MongoClient('mongodb://localhost');
|
|
9
|
+
}
|
|
10
|
+
get db() {
|
|
11
|
+
return this.client.db('app-data');
|
|
12
|
+
}
|
|
13
|
+
async createCollection(name) {
|
|
14
|
+
await this.db.createCollection(name);
|
|
15
|
+
}
|
|
16
|
+
async createIndexes(collection, indexes) {
|
|
17
|
+
this.logger.info({ indexes: indexes?.map(i => i.name) ?? null, collection: collection.collectionName }, 'Configured indexes');
|
|
18
|
+
const knownIndexes = await this.listIndexes(collection);
|
|
19
|
+
this.logger.info({ indexes: Array.from(knownIndexes), collection: collection.collectionName }, 'Known indexes');
|
|
20
|
+
knownIndexes.delete('_id_');
|
|
21
|
+
for (const index of indexes) {
|
|
22
|
+
if (knownIndexes.has(index.name)) {
|
|
23
|
+
knownIndexes.delete(index.name);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
this.logger.info({ index, collection: collection.collectionName }, 'Create index');
|
|
27
|
+
await collection.createIndexes([index]);
|
|
28
|
+
}
|
|
29
|
+
for (const name of knownIndexes) {
|
|
30
|
+
this.logger.info({ index: name, collection: collection.collectionName }, 'Drop index');
|
|
31
|
+
await collection.dropIndex(name);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async listIndexes(collection) {
|
|
35
|
+
const indexes = await collection.listIndexes().toArray();
|
|
36
|
+
return new Set(indexes.map((i) => {
|
|
37
|
+
if (i.name === undefined) {
|
|
38
|
+
throw Error('Index with undefined name detected!');
|
|
39
|
+
}
|
|
40
|
+
return i.name;
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export interface MongoObject<T> {
|
|
3
|
+
_id: string;
|
|
4
|
+
ownerId: string;
|
|
5
|
+
syncDate: Date;
|
|
6
|
+
data: T;
|
|
7
|
+
}
|
|
8
|
+
export declare const SYNC_OBJECT_SCHEMA: z.ZodObject<{
|
|
9
|
+
_id: z.ZodString;
|
|
10
|
+
data: z.ZodObject<{
|
|
11
|
+
lastModified: z.ZodString;
|
|
12
|
+
}, "strip", z.ZodTypeAny, {
|
|
13
|
+
lastModified: string;
|
|
14
|
+
}, {
|
|
15
|
+
lastModified: string;
|
|
16
|
+
}>;
|
|
17
|
+
}, "strip", z.ZodTypeAny, {
|
|
18
|
+
_id: string;
|
|
19
|
+
data: {
|
|
20
|
+
lastModified: string;
|
|
21
|
+
};
|
|
22
|
+
}, {
|
|
23
|
+
_id: string;
|
|
24
|
+
data: {
|
|
25
|
+
lastModified: string;
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
export interface SyncObject<T> {
|
|
29
|
+
_id: string;
|
|
30
|
+
ownerId: string;
|
|
31
|
+
syncDate: Date;
|
|
32
|
+
data: T;
|
|
33
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
import { Collection } from 'mongodb';
|
|
3
|
+
import { ApiComparisonObjectV0 } from '../../shared/api/sync';
|
|
4
|
+
import { SyncObject } from './model';
|
|
5
|
+
export declare function getAll<T>(c: Collection<SyncObject<T>>, ownerId: string, syncAfter?: DateTime<true>): Promise<ApiComparisonObjectV0[]>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { SYNC_OBJECT_SCHEMA } from './model';
|
|
2
|
+
export async function getAll(c, ownerId, syncAfter) {
|
|
3
|
+
const items = [];
|
|
4
|
+
const query = syncAfter === undefined
|
|
5
|
+
? { ownerId }
|
|
6
|
+
: { ownerId, syncDate: { $gt: syncAfter.toJSDate() } };
|
|
7
|
+
const cursor = c.find(query).project({ 'data.lastModified': 1 });
|
|
8
|
+
for await (const op of cursor) {
|
|
9
|
+
const parsed = SYNC_OBJECT_SCHEMA.parse(op);
|
|
10
|
+
items.push({
|
|
11
|
+
id: parsed._id,
|
|
12
|
+
lastModified: parsed.data.lastModified
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
return items;
|
|
16
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import z, { ZodType } from 'zod';
|
|
2
|
+
export declare class Forbidden extends Error {
|
|
3
|
+
constructor(url: string);
|
|
4
|
+
}
|
|
5
|
+
export interface AuthParam {
|
|
6
|
+
userId: string;
|
|
7
|
+
tempPassword: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function apiCall<T extends ZodType>(method: string, path: string, auth: AuthParam | null, queryString: Record<string, string> | null, request: object | null, validator: T): Promise<z.infer<T>>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { PRODUCTION } from '../../back/config';
|
|
2
|
+
const API_DOMAIN = PRODUCTION ? '/' : 'http://localhost:8080/';
|
|
3
|
+
export class Forbidden extends Error {
|
|
4
|
+
constructor(url) {
|
|
5
|
+
super(`Forbidden: ${url}`);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function url(path, queryString) {
|
|
9
|
+
const base = `${API_DOMAIN}api/v0/${path}`;
|
|
10
|
+
if (queryString === null) {
|
|
11
|
+
return base;
|
|
12
|
+
}
|
|
13
|
+
const query = Object.entries(queryString)
|
|
14
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
15
|
+
.join('&');
|
|
16
|
+
return `${base}?${query}`;
|
|
17
|
+
}
|
|
18
|
+
const GZIP_THRESHOLD = 16 * 1024;
|
|
19
|
+
async function prepareBody(request) {
|
|
20
|
+
const textBody = JSON.stringify(request);
|
|
21
|
+
if (textBody.length <= GZIP_THRESHOLD) {
|
|
22
|
+
return textBody;
|
|
23
|
+
}
|
|
24
|
+
const bodyStream = new Blob([textBody]).stream();
|
|
25
|
+
const bodyStreamCompressed = bodyStream.pipeThrough(new CompressionStream('gzip'));
|
|
26
|
+
return new Response(bodyStreamCompressed).blob();
|
|
27
|
+
}
|
|
28
|
+
export async function apiCall(method, path, auth, queryString, request, validator) {
|
|
29
|
+
const headers = {};
|
|
30
|
+
const body = request === null ? undefined : await prepareBody(request);
|
|
31
|
+
if (body !== undefined) {
|
|
32
|
+
headers['content-type'] = 'application/json';
|
|
33
|
+
if (typeof body !== 'string') {
|
|
34
|
+
headers['content-encoding'] = 'gzip';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (auth !== null) {
|
|
38
|
+
headers['authorization'] = `${auth.userId}:${auth.tempPassword}`;
|
|
39
|
+
}
|
|
40
|
+
const u = url(path, queryString);
|
|
41
|
+
const resp = await fetch(u, {
|
|
42
|
+
method,
|
|
43
|
+
headers,
|
|
44
|
+
body
|
|
45
|
+
});
|
|
46
|
+
if (!resp.ok) {
|
|
47
|
+
if (resp.status === 403) {
|
|
48
|
+
throw new Forbidden(u);
|
|
49
|
+
}
|
|
50
|
+
throw Error(`Not ok resp (${resp.status} ${resp.statusText}): ${method} ${u}`);
|
|
51
|
+
}
|
|
52
|
+
if (resp.status === 204) {
|
|
53
|
+
return validator.parse(undefined);
|
|
54
|
+
}
|
|
55
|
+
const json = await resp.json();
|
|
56
|
+
return validator.parse(json);
|
|
57
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { ApiAuthResponseV0 } from '../../shared/api/auth';
|
|
2
|
+
import { AuthParam } from './api';
|
|
3
|
+
export declare function apiSignup(name: string, email: string, password: string): Promise<ApiAuthResponseV0>;
|
|
4
|
+
export declare function apiSignin(name: string, password: string): Promise<ApiAuthResponseV0>;
|
|
5
|
+
export declare function apiLogout(auth: AuthParam): Promise<void>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { API_AUTH_RESPONSE_SCHEMA_V0 } from '../../shared/api/auth';
|
|
3
|
+
import { apiCall } from './api';
|
|
4
|
+
export async function apiSignup(name, email, password) {
|
|
5
|
+
const req = {
|
|
6
|
+
name,
|
|
7
|
+
email,
|
|
8
|
+
password
|
|
9
|
+
};
|
|
10
|
+
return apiCall('post', 'signup', null, null, req, API_AUTH_RESPONSE_SCHEMA_V0);
|
|
11
|
+
}
|
|
12
|
+
export async function apiSignin(name, password) {
|
|
13
|
+
const req = {
|
|
14
|
+
name,
|
|
15
|
+
password
|
|
16
|
+
};
|
|
17
|
+
return apiCall('post', 'signin', null, null, req, API_AUTH_RESPONSE_SCHEMA_V0);
|
|
18
|
+
}
|
|
19
|
+
export async function apiLogout(auth) {
|
|
20
|
+
await apiCall('post', 'logout', auth, null, null, z.undefined());
|
|
21
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Box, Stack, Typography } from '@mui/material';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Link } from 'react-router-dom';
|
|
4
|
+
export function NotFound() {
|
|
5
|
+
return (React.createElement(Stack, { width: '100vw', height: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' },
|
|
6
|
+
React.createElement(Box, { fontSize: 120 }, '404'),
|
|
7
|
+
React.createElement(Box, null,
|
|
8
|
+
React.createElement(Link, { to: '/' },
|
|
9
|
+
React.createElement(Typography, { color: 'warning.main', sx: { textDecoration: 'underline' } }, 'To main page')))));
|
|
10
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Box, Button, CircularProgress, Stack, Tab, Tabs, TextField, Typography } from '@mui/material';
|
|
2
|
+
import { observer } from 'mobx-react-lite';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { useCallback, useState } from 'react';
|
|
5
|
+
import { useLocation, useNavigate } from 'react-router-dom';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { apiSignin, apiSignup } from '../api/auth';
|
|
8
|
+
import { useAuthState } from '../state/auth';
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
10
|
+
export const SignupSigninScreen = observer(function SignupSigninScreen({ appName }) {
|
|
11
|
+
const location = useLocation();
|
|
12
|
+
const navigate = useNavigate();
|
|
13
|
+
const tab = location.pathname.substring(1);
|
|
14
|
+
return (React.createElement(Stack, { p: 1, gap: 1, maxWidth: '500px', mx: 'auto', mt: 10 },
|
|
15
|
+
React.createElement(Typography, { variant: 'h3', textAlign: 'center' }, appName),
|
|
16
|
+
React.createElement(Tabs, { value: tab, variant: 'fullWidth', onChange: (_, tab) => {
|
|
17
|
+
void navigate(`/${tab}`);
|
|
18
|
+
} },
|
|
19
|
+
React.createElement(Tab, { value: 'signin', label: 'Signin' }),
|
|
20
|
+
React.createElement(Tab, { value: 'signup', label: 'Signup' })),
|
|
21
|
+
tab === 'signup'
|
|
22
|
+
&& React.createElement(SignupForm, null),
|
|
23
|
+
tab === 'signin'
|
|
24
|
+
&& React.createElement(SigninForm, null)));
|
|
25
|
+
});
|
|
26
|
+
function SignupForm() {
|
|
27
|
+
const authState = useAuthState();
|
|
28
|
+
const navigate = useNavigate();
|
|
29
|
+
const [syncInProgress, setSyncInProgress] = useState(false);
|
|
30
|
+
const [name, setName] = useState('');
|
|
31
|
+
const [nameActivated, setNameActivated] = useState(false);
|
|
32
|
+
const [email, setEmail] = useState('');
|
|
33
|
+
const [emailActivated, setEmailActivated] = useState(false);
|
|
34
|
+
const [password, setPassword] = useState('');
|
|
35
|
+
const [passwordActivated, setPasswordActivated] = useState(false);
|
|
36
|
+
const [password2, setPassword2] = useState('');
|
|
37
|
+
const [password2Activated, setPassword2Activated] = useState(false);
|
|
38
|
+
const doSignUp = useCallback(() => {
|
|
39
|
+
setSyncInProgress(true);
|
|
40
|
+
setTimeout(async () => {
|
|
41
|
+
try {
|
|
42
|
+
await signup(name, email, password, password2, authState);
|
|
43
|
+
void navigate('/');
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
setSyncInProgress(false);
|
|
47
|
+
}
|
|
48
|
+
}, 0);
|
|
49
|
+
}, [name, email, password, password2]);
|
|
50
|
+
return (React.createElement(Stack, { gap: 2, mt: 2 },
|
|
51
|
+
React.createElement(TextField, { label: 'Name', value: name, error: nameActivated && name === '', helperText: nameActivated ? (name === '' ? 'Name sholud not be empty' : 'ok') : 'required', onChange: (e) => {
|
|
52
|
+
setNameActivated(true);
|
|
53
|
+
setName(e.target.value);
|
|
54
|
+
}, autoFocus: true }),
|
|
55
|
+
React.createElement(TextField, { label: 'E-mail', type: 'email', value: email, error: emailActivated && !isValidEmail(email), helperText: emailActivated ? (isValidEmail(email) ? 'ok' : 'Invalid e-mail address') : 'required', onChange: e => setEmail(e.target.value), onFocus: () => setEmailActivated(true) }),
|
|
56
|
+
React.createElement(TextField, { label: 'Password', type: 'password', value: password, error: passwordActivated && password === '', helperText: passwordActivated ? (password === '' ? 'Empty password' : 'ok') : 'required', onChange: e => setPassword(e.target.value), onFocus: () => setPasswordActivated(true) }),
|
|
57
|
+
React.createElement(TextField, { label: 'Confirm password', type: 'password', value: password2, error: password2Activated && password2 !== password, helperText: password2Activated ? (password2 !== password ? 'Do not match' : 'ok') : 'required', onChange: e => setPassword2(e.target.value), onFocus: () => setPassword2Activated(true), onKeyDown: (e) => {
|
|
58
|
+
if (e.key === 'Enter') {
|
|
59
|
+
doSignUp();
|
|
60
|
+
}
|
|
61
|
+
} }),
|
|
62
|
+
React.createElement(Box, { sx: { m: 1, position: 'relative' } },
|
|
63
|
+
React.createElement(Button, { variant: 'contained', fullWidth: true, disabled: name === ''
|
|
64
|
+
|| !isValidEmail(email)
|
|
65
|
+
|| password === ''
|
|
66
|
+
|| password2 !== password
|
|
67
|
+
|| syncInProgress, onClick: doSignUp }, 'Signup'),
|
|
68
|
+
syncInProgress && (React.createElement(CircularProgress, { size: 24, sx: {
|
|
69
|
+
position: 'absolute',
|
|
70
|
+
top: '50%',
|
|
71
|
+
left: '50%',
|
|
72
|
+
marginTop: '-12px',
|
|
73
|
+
marginLeft: '-12px'
|
|
74
|
+
} })))));
|
|
75
|
+
}
|
|
76
|
+
async function signup(name, email, password, password2, authState) {
|
|
77
|
+
if (name === ''
|
|
78
|
+
|| !isValidEmail(email)
|
|
79
|
+
|| password === ''
|
|
80
|
+
|| password2 !== password) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const resp = await apiSignup(name, email, password);
|
|
84
|
+
authState.login(resp.id, resp.name, resp.email, resp.tempPassword);
|
|
85
|
+
}
|
|
86
|
+
function SigninForm() {
|
|
87
|
+
const authState = useAuthState();
|
|
88
|
+
const [syncInProgress, setSyncInProgress] = useState(false);
|
|
89
|
+
const navigate = useNavigate();
|
|
90
|
+
const [name, setName] = useState('');
|
|
91
|
+
const [password, setPassword] = useState('');
|
|
92
|
+
const doSignIn = useCallback(() => {
|
|
93
|
+
setSyncInProgress(true);
|
|
94
|
+
setTimeout(async () => {
|
|
95
|
+
try {
|
|
96
|
+
await signin(name, password, authState);
|
|
97
|
+
void navigate('/');
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
setSyncInProgress(false);
|
|
101
|
+
}
|
|
102
|
+
}, 0);
|
|
103
|
+
}, [name, password]);
|
|
104
|
+
return (React.createElement(Stack, { gap: 2, mt: 2 },
|
|
105
|
+
React.createElement(TextField, { label: 'Name', value: name, onChange: e => setName(e.target.value), autoFocus: true }),
|
|
106
|
+
React.createElement(TextField, { label: 'Password', type: 'password', value: password, onChange: e => setPassword(e.target.value), onKeyDown: (e) => {
|
|
107
|
+
if (e.key === 'Enter') {
|
|
108
|
+
doSignIn();
|
|
109
|
+
}
|
|
110
|
+
} }),
|
|
111
|
+
React.createElement(Box, { sx: { m: 1, position: 'relative' } },
|
|
112
|
+
React.createElement(Button, { variant: 'contained', fullWidth: true, disabled: name === ''
|
|
113
|
+
|| password === ''
|
|
114
|
+
|| syncInProgress, onClick: doSignIn }, 'Signin'),
|
|
115
|
+
syncInProgress && (React.createElement(CircularProgress, { size: 24, sx: {
|
|
116
|
+
position: 'absolute',
|
|
117
|
+
top: '50%',
|
|
118
|
+
left: '50%',
|
|
119
|
+
marginTop: '-12px',
|
|
120
|
+
marginLeft: '-12px'
|
|
121
|
+
} })))));
|
|
122
|
+
}
|
|
123
|
+
async function signin(name, password, authState) {
|
|
124
|
+
const resp = await apiSignin(name, password);
|
|
125
|
+
authState.login(resp.id, resp.name, resp.email, resp.tempPassword);
|
|
126
|
+
}
|
|
127
|
+
function isValidEmail(s) {
|
|
128
|
+
return z.string().email().safeParse(s).success;
|
|
129
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { AuthParam } from '../api/api';
|
|
2
|
+
export declare class AuthState {
|
|
3
|
+
id: string | null;
|
|
4
|
+
name: string | null;
|
|
5
|
+
email: string | null;
|
|
6
|
+
tempPassword: string | null;
|
|
7
|
+
constructor();
|
|
8
|
+
logout(): void;
|
|
9
|
+
login(id: string, name: string, email: string, tempPassword: string): void;
|
|
10
|
+
get authParam(): AuthParam | null;
|
|
11
|
+
}
|
|
12
|
+
export declare function useAuthState(): AuthState;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { autorun, makeAutoObservable } from 'mobx';
|
|
2
|
+
const AUTH_ID_KEY = 'AUTH_ID';
|
|
3
|
+
const AUTH_NAME_KEY = 'AUTH_NAME';
|
|
4
|
+
const AUTH_EMAIL_KEY = 'AUTH_EMAIL';
|
|
5
|
+
const AUTH_TEMP_PASSWORD_KEY = 'AUTH_TEMP_PASSWORD';
|
|
6
|
+
export class AuthState {
|
|
7
|
+
id = localStorage.getItem(AUTH_ID_KEY);
|
|
8
|
+
name = localStorage.getItem(AUTH_NAME_KEY);
|
|
9
|
+
email = localStorage.getItem(AUTH_EMAIL_KEY);
|
|
10
|
+
tempPassword = localStorage.getItem(AUTH_TEMP_PASSWORD_KEY);
|
|
11
|
+
constructor() {
|
|
12
|
+
makeAutoObservable(this);
|
|
13
|
+
autorun(() => {
|
|
14
|
+
if (this.id !== null) {
|
|
15
|
+
localStorage.setItem(AUTH_ID_KEY, this.id);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
localStorage.removeItem(AUTH_ID_KEY);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
autorun(() => {
|
|
22
|
+
if (this.name !== null) {
|
|
23
|
+
localStorage.setItem(AUTH_NAME_KEY, this.name);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
localStorage.removeItem(AUTH_NAME_KEY);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
autorun(() => {
|
|
30
|
+
if (this.email !== null) {
|
|
31
|
+
localStorage.setItem(AUTH_EMAIL_KEY, this.email);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
localStorage.removeItem(AUTH_EMAIL_KEY);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
autorun(() => {
|
|
38
|
+
if (this.tempPassword !== null) {
|
|
39
|
+
localStorage.setItem(AUTH_TEMP_PASSWORD_KEY, this.tempPassword);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
localStorage.removeItem(AUTH_TEMP_PASSWORD_KEY);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
logout() {
|
|
47
|
+
this.id = null;
|
|
48
|
+
this.name = null;
|
|
49
|
+
this.email = null;
|
|
50
|
+
this.tempPassword = null;
|
|
51
|
+
}
|
|
52
|
+
login(id, name, email, tempPassword) {
|
|
53
|
+
this.id = id;
|
|
54
|
+
this.name = name;
|
|
55
|
+
this.email = email;
|
|
56
|
+
this.tempPassword = tempPassword;
|
|
57
|
+
}
|
|
58
|
+
get authParam() {
|
|
59
|
+
if (this.id === null || this.tempPassword === null) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
userId: this.id,
|
|
64
|
+
tempPassword: this.tempPassword
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
let state = null;
|
|
69
|
+
export function useAuthState() {
|
|
70
|
+
if (state === null) {
|
|
71
|
+
state = new AuthState();
|
|
72
|
+
}
|
|
73
|
+
return state;
|
|
74
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
import { API_COMPARISON_OBJECT_SCHEMA_V0, ApiItemsResponseV0 } from '../shared/api/sync';
|
|
3
|
+
export declare function syncItems<T extends {
|
|
4
|
+
id: string;
|
|
5
|
+
lastModified: DateTime<true>;
|
|
6
|
+
}>({ getRemoteLastModified, localItems, pushRemote, getRemote, pushLocal, lastSyncDate }: {
|
|
7
|
+
getRemoteLastModified: () => Promise<ApiItemsResponseV0<typeof API_COMPARISON_OBJECT_SCHEMA_V0>>;
|
|
8
|
+
localItems: readonly T[];
|
|
9
|
+
pushRemote: (items: readonly T[]) => Promise<void>;
|
|
10
|
+
getRemote: (ids: readonly string[]) => Promise<T[]>;
|
|
11
|
+
pushLocal: (items: readonly T[]) => void;
|
|
12
|
+
lastSyncDate: DateTime<true> | null;
|
|
13
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
import { toValid } from '../shared/utils/datetime';
|
|
3
|
+
export async function syncItems({ getRemoteLastModified, localItems, pushRemote, getRemote, pushLocal, lastSyncDate }) {
|
|
4
|
+
const remoteItems = (await getRemoteLastModified()).items;
|
|
5
|
+
const remoteItemsMap = Object.fromEntries(remoteItems.map(i => [i.id, toValid(DateTime.fromISO(i.lastModified, { zone: 'utc' }))]));
|
|
6
|
+
const itemsToGet = [];
|
|
7
|
+
const itemsToPush = [];
|
|
8
|
+
for (const i of localItems) {
|
|
9
|
+
const ri = remoteItemsMap[i.id];
|
|
10
|
+
if (ri === undefined) {
|
|
11
|
+
if (lastSyncDate === null || i.lastModified > lastSyncDate) {
|
|
12
|
+
itemsToPush.push(i);
|
|
13
|
+
}
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (ri.equals(i.lastModified)) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (ri < i.lastModified) {
|
|
20
|
+
itemsToPush.push(i);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
itemsToGet.push(i.id);
|
|
24
|
+
}
|
|
25
|
+
const knownItems = new Set(localItems.map(i => i.id));
|
|
26
|
+
for (const { id } of remoteItems) {
|
|
27
|
+
if (!knownItems.has(id)) {
|
|
28
|
+
itemsToGet.push(id);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (itemsToPush.length > 0) {
|
|
32
|
+
await pushRemote(itemsToPush);
|
|
33
|
+
}
|
|
34
|
+
if (itemsToGet.length > 0) {
|
|
35
|
+
pushLocal((await getRemote(itemsToGet)));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const API_AUTH_RESPONSE_SCHEMA_V0: z.ZodObject<{
|
|
3
|
+
id: z.ZodString;
|
|
4
|
+
name: z.ZodString;
|
|
5
|
+
email: z.ZodString;
|
|
6
|
+
tempPassword: z.ZodString;
|
|
7
|
+
}, "strip", z.ZodTypeAny, {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
email: string;
|
|
11
|
+
tempPassword: string;
|
|
12
|
+
}, {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
email: string;
|
|
16
|
+
tempPassword: string;
|
|
17
|
+
}>;
|
|
18
|
+
export type ApiAuthResponseV0 = z.infer<typeof API_AUTH_RESPONSE_SCHEMA_V0>;
|
|
19
|
+
export declare const API_SIGNUP_REQUEST_SCHEMA_V0: z.ZodObject<{
|
|
20
|
+
name: z.ZodString;
|
|
21
|
+
email: z.ZodString;
|
|
22
|
+
password: z.ZodString;
|
|
23
|
+
}, "strip", z.ZodTypeAny, {
|
|
24
|
+
name: string;
|
|
25
|
+
email: string;
|
|
26
|
+
password: string;
|
|
27
|
+
}, {
|
|
28
|
+
name: string;
|
|
29
|
+
email: string;
|
|
30
|
+
password: string;
|
|
31
|
+
}>;
|
|
32
|
+
export type ApiSignupRequestV0 = z.infer<typeof API_SIGNUP_REQUEST_SCHEMA_V0>;
|
|
33
|
+
export declare const API_SIGNIN_REQUEST_SCHEMA_V0: z.ZodObject<{
|
|
34
|
+
name: z.ZodString;
|
|
35
|
+
password: z.ZodString;
|
|
36
|
+
}, "strip", z.ZodTypeAny, {
|
|
37
|
+
name: string;
|
|
38
|
+
password: string;
|
|
39
|
+
}, {
|
|
40
|
+
name: string;
|
|
41
|
+
password: string;
|
|
42
|
+
}>;
|
|
43
|
+
export type ApiSigninRequestV0 = z.infer<typeof API_SIGNIN_REQUEST_SCHEMA_V0>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const API_AUTH_RESPONSE_SCHEMA_V0 = z.object({
|
|
3
|
+
id: z.string().uuid(),
|
|
4
|
+
name: z.string(),
|
|
5
|
+
email: z.string().email(),
|
|
6
|
+
tempPassword: z.string()
|
|
7
|
+
});
|
|
8
|
+
export const API_SIGNUP_REQUEST_SCHEMA_V0 = z.object({
|
|
9
|
+
name: z.string(),
|
|
10
|
+
email: z.string().email(),
|
|
11
|
+
password: z.string()
|
|
12
|
+
});
|
|
13
|
+
export const API_SIGNIN_REQUEST_SCHEMA_V0 = z.object({
|
|
14
|
+
name: z.string(),
|
|
15
|
+
password: z.string()
|
|
16
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import z, { ZodType } from 'zod';
|
|
2
|
+
export declare const API_GET_OBJECTS_QUERY_STRING_SCHEMA_V0: z.ZodObject<{
|
|
3
|
+
syncAfter: z.ZodOptional<z.ZodString>;
|
|
4
|
+
}, "strip", z.ZodTypeAny, {
|
|
5
|
+
syncAfter?: string | undefined;
|
|
6
|
+
}, {
|
|
7
|
+
syncAfter?: string | undefined;
|
|
8
|
+
}>;
|
|
9
|
+
export declare function API_ITEMS_RESPONSE_SCHEMA_V0<T extends ZodType>(itemsSchema: T): z.ZodReadonly<z.ZodObject<{
|
|
10
|
+
items: z.ZodReadonly<z.ZodArray<T, "many">>;
|
|
11
|
+
}, "strip", z.ZodTypeAny, {
|
|
12
|
+
items: readonly T["_output"][];
|
|
13
|
+
}, {
|
|
14
|
+
items: readonly T["_input"][];
|
|
15
|
+
}>>;
|
|
16
|
+
export type ApiItemsResponseV0<T extends ZodType> = z.infer<ReturnType<typeof API_ITEMS_RESPONSE_SCHEMA_V0<T>>>;
|
|
17
|
+
export declare const API_GET_OBJECTS_REQUEST_SCHEMA_V0: z.ZodReadonly<z.ZodObject<{
|
|
18
|
+
ids: z.ZodReadonly<z.ZodArray<z.ZodString, "many">>;
|
|
19
|
+
}, "strip", z.ZodTypeAny, {
|
|
20
|
+
ids: readonly string[];
|
|
21
|
+
}, {
|
|
22
|
+
ids: readonly string[];
|
|
23
|
+
}>>;
|
|
24
|
+
export type ApiGetObjectsRequestV0 = z.infer<typeof API_GET_OBJECTS_REQUEST_SCHEMA_V0>;
|
|
25
|
+
export declare const API_COMPARISON_OBJECT_SCHEMA_V0: z.ZodObject<{
|
|
26
|
+
id: z.ZodString;
|
|
27
|
+
lastModified: z.ZodString;
|
|
28
|
+
}, "strip", z.ZodTypeAny, {
|
|
29
|
+
lastModified: string;
|
|
30
|
+
id: string;
|
|
31
|
+
}, {
|
|
32
|
+
lastModified: string;
|
|
33
|
+
id: string;
|
|
34
|
+
}>;
|
|
35
|
+
export type ApiComparisonObjectV0 = z.infer<typeof API_COMPARISON_OBJECT_SCHEMA_V0>;
|
|
36
|
+
export declare function API_ITEMS_REQUEST_SCHEMA_V0<T extends ZodType>(itemsSchema: T): z.ZodReadonly<z.ZodObject<{
|
|
37
|
+
items: z.ZodReadonly<z.ZodArray<T, "many">>;
|
|
38
|
+
}, "strip", z.ZodTypeAny, {
|
|
39
|
+
items: readonly T["_output"][];
|
|
40
|
+
}, {
|
|
41
|
+
items: readonly T["_input"][];
|
|
42
|
+
}>>;
|
|
43
|
+
export type ApiItemsRequestV0<T extends ZodType> = z.infer<ReturnType<typeof API_ITEMS_REQUEST_SCHEMA_V0<T>>>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
export const API_GET_OBJECTS_QUERY_STRING_SCHEMA_V0 = z.object({
|
|
3
|
+
syncAfter: z.string().datetime().optional()
|
|
4
|
+
});
|
|
5
|
+
export function API_ITEMS_RESPONSE_SCHEMA_V0(itemsSchema) {
|
|
6
|
+
return z.object({
|
|
7
|
+
items: z.array(itemsSchema).readonly()
|
|
8
|
+
}).readonly();
|
|
9
|
+
}
|
|
10
|
+
export const API_GET_OBJECTS_REQUEST_SCHEMA_V0 = z.object({
|
|
11
|
+
ids: z.array(z.string().uuid()).min(1).readonly()
|
|
12
|
+
}).readonly();
|
|
13
|
+
export const API_COMPARISON_OBJECT_SCHEMA_V0 = z.object({
|
|
14
|
+
id: z.string().uuid(),
|
|
15
|
+
lastModified: z.string().datetime()
|
|
16
|
+
});
|
|
17
|
+
export function API_ITEMS_REQUEST_SCHEMA_V0(itemsSchema) {
|
|
18
|
+
return z.object({
|
|
19
|
+
items: z.array(itemsSchema).min(1).readonly()
|
|
20
|
+
}).readonly();
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
export function toValid(datetime) {
|
|
3
|
+
if (!datetime.isValid) {
|
|
4
|
+
throw Error('Invalid DateTime');
|
|
5
|
+
}
|
|
6
|
+
return datetime;
|
|
7
|
+
}
|
|
8
|
+
export function utcToday() {
|
|
9
|
+
const local = DateTime.local();
|
|
10
|
+
const utc = DateTime.utc(local.year, local.month, local.day);
|
|
11
|
+
if (!utc.isValid) {
|
|
12
|
+
throw Error('Invalid DateTime');
|
|
13
|
+
}
|
|
14
|
+
return utc;
|
|
15
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rlz-engine",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Deps and tools for my style of development",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "tsc"
|
|
7
|
+
},
|
|
8
|
+
"author": "Dmitry Maslennikov <maslennikovdm@gmail.com>",
|
|
9
|
+
"license": "ISC",
|
|
10
|
+
"homepage": "https://github.com/rlz/rlz-engine",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/rlz/rlz-engine/issues"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/rlz/rlz-engine.git"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@stylistic/eslint-plugin": "^2.11.0",
|
|
24
|
+
"@types/luxon": "^3.4.2",
|
|
25
|
+
"@types/node": "^22.10.1",
|
|
26
|
+
"@typescript-eslint/parser": "^8.16.0",
|
|
27
|
+
"eslint": "^9.16.0",
|
|
28
|
+
"eslint-plugin-simple-import-sort": "^12.1.1",
|
|
29
|
+
"tsx": "^4.19.2",
|
|
30
|
+
"typescript": "^5.7.2",
|
|
31
|
+
"typescript-eslint": "^8.16.0"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@emotion/react": "^11.14.0",
|
|
35
|
+
"@emotion/styled": "^11.14.0",
|
|
36
|
+
"@fastify/compress": "^8.0.1",
|
|
37
|
+
"@fastify/cors": "^10.0.1",
|
|
38
|
+
"@fastify/response-validation": "^3.0.2",
|
|
39
|
+
"@fastify/sensible": "^6.0.1",
|
|
40
|
+
"@fastify/static": "^8.0.3",
|
|
41
|
+
"@mui/icons-material": "^6.3.0",
|
|
42
|
+
"@mui/material": "^6.3.0",
|
|
43
|
+
"@types/react": "^19.0.2",
|
|
44
|
+
"@types/react-dom": "^19.0.2",
|
|
45
|
+
"@types/react-router-dom": "^5.3.3",
|
|
46
|
+
"acme-client": "^5.4.0",
|
|
47
|
+
"ajv-formats": "^3.0.1",
|
|
48
|
+
"fastify": "^5.1.0",
|
|
49
|
+
"fastify-acme": "^1.0.5",
|
|
50
|
+
"fastify-plugin": "^5.0.1",
|
|
51
|
+
"iterator-helpers-polyfill": "^3.0.1",
|
|
52
|
+
"luxon": "^3.5.0",
|
|
53
|
+
"mobx": "^6.13.5",
|
|
54
|
+
"mobx-react-lite": "^4.1.0",
|
|
55
|
+
"mongodb": "^6.12.0",
|
|
56
|
+
"pino": "^9.5.0",
|
|
57
|
+
"pino-pretty": "^13.0.0",
|
|
58
|
+
"react": "^19.0.0",
|
|
59
|
+
"react-dom": "^19.0.0",
|
|
60
|
+
"react-router-dom": "^7.1.1",
|
|
61
|
+
"uuidv7": "^1.0.2",
|
|
62
|
+
"zod": "^3.23.8",
|
|
63
|
+
"zod-to-json-schema": "^3.23.5"
|
|
64
|
+
}
|
|
65
|
+
}
|