Skip to content

Commit

Permalink
feat: Database migration (closes #29)
Browse files Browse the repository at this point in the history
  • Loading branch information
Gustl22 committed Sep 1, 2024
1 parent 31d836f commit ae53831
Show file tree
Hide file tree
Showing 13 changed files with 204 additions and 54 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
-- PostgreSQL database dump
--

-- Dumped from database version 13.6 (Ubuntu 13.6-0ubuntu0.21.10.1)
-- Dumped by pg_dump version 16.4 (Ubuntu 16.4-0ubuntu0.24.04.1)
-- Dumped from database version 16.1
-- Dumped by pg_dump version 16.1

SET statement_timeout = 0;
SET lock_timeout = 0;
Expand Down Expand Up @@ -679,6 +679,17 @@ ALTER SEQUENCE public.membership_id_seq OWNER TO wrestling;
ALTER SEQUENCE public.membership_id_seq OWNED BY public.membership.id;


--
-- Name: migration; Type: TABLE; Schema: public; Owner: wrestling
--

CREATE TABLE public.migration (
semver character varying(127) DEFAULT '0.0.0'::character varying NOT NULL
);


ALTER TABLE public.migration OWNER TO wrestling;

--
-- Name: organization; Type: TABLE; Schema: public; Owner: wrestling
--
Expand Down Expand Up @@ -1397,6 +1408,15 @@ COPY public.membership (id, person_id, club_id, no, org_sync_id, organization_id
\.


--
-- Data for Name: migration; Type: TABLE DATA; Schema: public; Owner: wrestling
--

COPY public.migration (semver) FROM stdin;
0.0.1-beta.14
\.


--
-- Data for Name: organization; Type: TABLE DATA; Schema: public; Owner: wrestling
--
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
create table migration
(
semver varchar(127) default '0.0.0'::character varying not null
);
alter table migration owner to wrestling;
INSERT INTO public.migration (semver) VALUES ('0.0.0');

CREATE TYPE public.user_privilege AS ENUM (
'none',
'read',
'write',
'admin'
);
ALTER TYPE public.user_privilege OWNER TO wrestling;

alter table public.bout add organization_id integer;

alter table public.division_weight_class add organization_id integer;
alter table public.division_weight_class add org_sync_id varchar(127);

alter table public.league add bout_days integer DEFAULT 14 NOT NULL;

CREATE TABLE public.secured_user (
id integer NOT NULL,
username character varying(127) NOT NULL,
password_hash bytea NOT NULL,
email character varying(127),
person_id integer,
salt character varying(127) NOT NULL,
created_at date NOT NULL,
privilege public.user_privilege DEFAULT 'none'::public.user_privilege NOT NULL
);
ALTER TABLE public.secured_user OWNER TO wrestling;

CREATE SEQUENCE public.user_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.user_id_seq OWNER TO wrestling;
ALTER SEQUENCE public.user_id_seq OWNED BY public.secured_user.id;
ALTER TABLE ONLY public.secured_user ALTER COLUMN id SET DEFAULT nextval('public.user_id_seq'::regclass);

COPY public.secured_user (id, username, password_hash, email, person_id, salt, created_at, privilege) FROM stdin;
1 admin \\xb2950268d52c1d17f1b35edd35c071be3d320b488c81425c6b144340963e524a \N 924VOg== 2024-08-25 admin
\.

SELECT pg_catalog.setval('public.user_id_seq', 1, true);

ALTER TABLE ONLY public.secured_user ADD CONSTRAINT user_pk PRIMARY KEY (id);
ALTER TABLE ONLY public.secured_user ADD CONSTRAINT user_pk_2 UNIQUE (username);

ALTER TABLE ONLY public.bout ADD CONSTRAINT bout_organization_id_fk FOREIGN KEY (organization_id) REFERENCES public.organization(id);
ALTER TABLE ONLY public.division_weight_class ADD CONSTRAINT division_weight_class_organization_id_fk FOREIGN KEY (organization_id) REFERENCES public.organization(id);

ALTER TABLE ONLY public.secured_user ADD CONSTRAINT user_person_id_fk FOREIGN KEY (person_id) REFERENCES public.person(id) ON DELETE CASCADE;

Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:shelf/shelf.dart';
import 'package:wrestling_scoreboard_common/common.dart';
import 'package:wrestling_scoreboard_server/controllers/user_controller.dart';
import 'package:wrestling_scoreboard_server/server.dart';
import 'package:wrestling_scoreboard_server/services/environment.dart';

class AuthController {
late final String jwtSecret;

AuthController() {
final jwtSecret = env['JWT_SECRET'];
final jwtSecret = env.jwtSecret;
if (jwtSecret == null) {
throw Exception('JWT_SECRET not specified!');
}
Expand Down Expand Up @@ -66,8 +66,8 @@ class AuthController {
}

String _signToken(SecuredUser user) {
final int jwtExpiresInDays = int.parse(env['JWT_EXPIRES_IN_DAYS'] ?? '90');
final jwtIssuer = env['JWT_ISSUER'] ?? 'Wrestling Scoreboard (oberhauser.dev)';
final int jwtExpiresInDays = env.jwtExpiresInDays ?? 90;
final jwtIssuer = env.jwtIssuer ?? 'Wrestling Scoreboard (oberhauser.dev)';

final jwt = JWT(
// Payload
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:io';

import 'package:pub_semver/pub_semver.dart';
import 'package:shelf/shelf.dart';
import 'package:wrestling_scoreboard_common/common.dart';
import 'package:wrestling_scoreboard_server/controllers/user_controller.dart';
Expand All @@ -8,7 +9,34 @@ import 'package:wrestling_scoreboard_server/services/postgres_db.dart';
import 'entity_controller.dart';

class DatabaseController {
// TODO: migration should be handled automatically at server start.
Future<void> migrate() async {
final db = PostgresDb();

final res = await db.connection.execute('SELECT semver FROM migration LIMIT 1;');
final row = res.singleOrNull;
final String semver = row?.toColumnMap()['semver'] ?? '0.0.0';
final databaseVersion = Version.parse(semver);

final dir = Directory('database/migration');
final List<FileSystemEntity> entities = await dir.list().toList();
final migrationMap = entities.map((entity) {
final migrationVersion = entity.uri.pathSegments.last.split('_')[0];
return MapEntry(Version.parse(migrationVersion.replaceFirst('v', '')), entity);
}).toList();
migrationMap.sort((a, b) => a.key.compareTo(b.key));
int migrationStartIndex = 0;
while (migrationStartIndex < migrationMap.length) {
if (databaseVersion.compareTo(migrationMap[migrationStartIndex].key) < 0) {
break;
}
migrationStartIndex++;
}
final migrationRange = migrationMap.sublist(migrationStartIndex, migrationMap.length);
for (var migration in migrationRange) {
_executeSqlFile(migration.value.path);
}
await db.connection.execute("UPDATE migration SET semver = '${migrationRange.last.key.toString()}';");
}

/// Reset all tables
Future<Response> reset(Request request, User? user) async {
Expand All @@ -17,7 +45,10 @@ class DatabaseController {
Iterable<ShelfController> entityControllers = dataTypes.map((t) => ShelfController.getControllerFromDataType(t));
// Remove data
await Future.forEach(entityControllers, (e) => e.deleteMany());
// Create default admin
// No need to restore the database semantic version for migration,
// as it is no entity controller and therefore keeps it default value.

// Recreate default admin
await SecuredUserController().createSingle(
User(username: 'admin', createdAt: DateTime.now(), privilege: UserPrivilege.admin, password: 'admin')
.toSecuredUser());
Expand Down Expand Up @@ -82,14 +113,17 @@ class DatabaseController {

Future<void> _restore(String dumpPath) async {
final db = PostgresDb();
{
await db.connection.execute('DROP SCHEMA IF EXISTS public CASCADE;');
await db.close();
}
await db.connection.execute('DROP SCHEMA IF EXISTS public CASCADE;');
await _executeSqlFile(dumpPath);
}

Future<void> _executeSqlFile(String sqlFilePath) async {
final db = PostgresDb();
await db.close();

final args = <String>[
'--file',
dumpPath,
sqlFilePath,
'--username',
db.dbUser,
'--host',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import 'package:wrestling_scoreboard_server/controllers/participation_controller
import 'package:wrestling_scoreboard_server/controllers/team_controller.dart';
import 'package:wrestling_scoreboard_server/controllers/team_match_bout_controller.dart';
import 'package:wrestling_scoreboard_server/controllers/team_match_controller.dart';
import 'package:wrestling_scoreboard_server/server.dart';
import 'package:wrestling_scoreboard_server/services/auth.dart';
import 'package:wrestling_scoreboard_server/services/environment.dart';

import 'entity_controller.dart';
import 'league_team_participation_controller.dart';
Expand Down Expand Up @@ -419,5 +419,5 @@ final websocketHandler = webSocketHandler(
webSocketPool.remove(webSocket);
});
},
pingInterval: Duration(seconds: int.parse(env['WEB_SOCKET_PING_INTERVAL_SECS'] ?? '30')),
pingInterval: Duration(seconds: env.webSocketPingIntervalSecs ?? 30),
);
4 changes: 2 additions & 2 deletions wrestling_scoreboard_server/lib/middleware/cors.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import 'package:wrestling_scoreboard_server/server.dart';
import 'package:shelf/shelf.dart';
import 'package:wrestling_scoreboard_server/services/environment.dart';

final corsHeaders = {
'Access-Control-Allow-Origin': env['CORS_ALLOW_ORIGIN'] ?? '*',
'Access-Control-Allow-Origin': env.corsAllowOrigin ?? '*',
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS, PUT',
'Access-Control-Allow-Headers': '*',
};
Expand Down
31 changes: 6 additions & 25 deletions wrestling_scoreboard_server/lib/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,23 @@

import 'dart:io';

import 'package:dotenv/dotenv.dart' show DotEnv;
import 'package:logging/logging.dart';
import 'package:pubspec_parse/pubspec_parse.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart' as shelf_router;
import 'package:shelf_static/shelf_static.dart' as shelf_static;
import 'package:wrestling_scoreboard_server/controllers/websocket_handler.dart';
import 'package:wrestling_scoreboard_server/services/environment.dart';
import 'package:wrestling_scoreboard_server/services/pubspec.dart';
import 'package:wrestling_scoreboard_server/routes/api_route.dart';
import 'package:wrestling_scoreboard_server/services/postgres_db.dart';

import 'middleware/cors.dart';

final env = DotEnv();

Pubspec? pubspec;

Future<Pubspec> _parsePubspec() async {
if (pubspec == null) {
final file = File('pubspec.yaml');
if (await file.exists()) {
pubspec = Pubspec.parse(await file.readAsString());
} else {
throw FileSystemException('No file found', file.absolute.path);
}
}
return pubspec!;
}

Future init() async {
env.load(); // Load dotenv variables
await _parsePubspec();

// Init logger
Logger.root.level =
Level.LEVELS.where((level) => level.name == env['LOG_LEVEL']?.toUpperCase()).singleOrNull ?? Level.INFO;
Logger.root.level = env.logLevel ?? Level.INFO;
Logger.root.onRecord.listen(
(record) async {
String text = '[${record.time}] ${record.level.name}: ${record.message}';
Expand All @@ -64,7 +45,7 @@ Future init() async {

// If the "PORT" environment variable is set, listen to it. Otherwise, 8080.
// https://cloud.google.com/run/docs/reference/container-contract#port
final port = int.parse(env['PORT'] ?? '8080');
final port = env.port ?? 8080;

// Must open the database before initializing any routes.
await PostgresDb().open();
Expand All @@ -89,7 +70,7 @@ Future init() async {
}
})
..mount('/about', (Request request) async {
final pubspec = await _parsePubspec();
final pubspec = await parsePubspec();
return Response.ok('''
Name: ${pubspec.name}
Description: ${pubspec.description}
Expand Down Expand Up @@ -117,7 +98,7 @@ Future init() async {
// See https://pub.dev/documentation/shelf/latest/shelf_io/serve.html
final server = await shelf_io.serve(
pipeline,
env['HOST'] ?? InternetAddress.anyIPv4, // Allows external connections
env.host ?? InternetAddress.anyIPv4, // Allows external connections
port,
);

Expand Down
9 changes: 4 additions & 5 deletions wrestling_scoreboard_server/lib/services/auth.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:shelf/shelf.dart';
import 'package:wrestling_scoreboard_common/common.dart';
import 'package:wrestling_scoreboard_server/controllers/user_controller.dart';
import 'package:wrestling_scoreboard_server/server.dart';

import 'environment.dart';

extension AuthRequest on Request {
Future<Response> restricted({
Expand Down Expand Up @@ -37,14 +38,12 @@ extension AuthRequest on Request {
}
}

final jwtSecret = env['JWT_SECRET'];

extension UserFromAuthService on BearerAuthService {
Future<User?> getUser() async {
if (jwtSecret == null) {
if (env.jwtSecret == null) {
throw Exception('JWT_SECRET not specified!');
}
final jwt = JWT.verify(token, SecretKey(jwtSecret!));
final jwt = JWT.verify(token, SecretKey(env.jwtSecret!));
final user = await SecuredUserController().getSingleByUsername(jwt.payload['username'] as String);
return user?.toUser();
}
Expand Down
38 changes: 38 additions & 0 deletions wrestling_scoreboard_server/lib/services/environment.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'package:dotenv/dotenv.dart' show DotEnv;
import 'package:logging/logging.dart';

final env = Environment();

class Environment {
late final Level? logLevel;
late final String? host;
late final int? port;
late final String? jwtSecret;
late final int? jwtExpiresInDays;
late final String? jwtIssuer;
late final String? databaseHost;
late final int? databasePort;
late final String? databaseUser;
late final String? databasePassword;
late final String? databaseName;
late final String? corsAllowOrigin;
late final int? webSocketPingIntervalSecs;

Environment() {
final dotEnv = DotEnv();
dotEnv.load(); // Load dotenv variables
logLevel = Level.LEVELS.where((level) => level.name == dotEnv['LOG_LEVEL']?.toUpperCase()).singleOrNull;
host = dotEnv['HOST'];
port = int.tryParse(dotEnv['PORT'] ?? '');
jwtSecret = dotEnv['JWT_SECRET'];
jwtExpiresInDays = int.tryParse(dotEnv['JWT_EXPIRES_IN_DAYS'] ?? '');
jwtIssuer = dotEnv['JWT_ISSUER'];
databaseHost = dotEnv['DATABASE_HOST'];
databasePort = int.tryParse(dotEnv['DATABASE_PORT'] ?? '');
databaseUser = dotEnv['DATABASE_USER'];
databasePassword = dotEnv['DATABASE_PASSWORD'];
databaseName = dotEnv['DATABASE_NAME'];
corsAllowOrigin = dotEnv['CORS_ALLOW_ORIGIN'];
webSocketPingIntervalSecs = int.tryParse(dotEnv['WEB_SOCKET_PING_INTERVAL_SECS'] ?? '');
}
}
13 changes: 7 additions & 6 deletions wrestling_scoreboard_server/lib/services/postgres_db.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import 'package:postgres/postgres.dart' as psql;
import 'package:wrestling_scoreboard_server/server.dart';

import 'environment.dart';

const _isReleaseMode = bool.fromEnvironment("dart.vm.product");

class PostgresDb {
final String postgresHost = env['DATABASE_HOST'] ?? 'localhost';
final int postgresPort = int.parse(env['DATABASE_PORT'] ?? '5432');
final String dbUser = env['DATABASE_USER'] ?? 'postgres';
final String dbPW = env['DATABASE_PASSWORD'] ?? '';
final String postgresDatabaseName = env['DATABASE_NAME'] ?? 'wrestling_scoreboard';
final String postgresHost = env.databaseHost ?? 'localhost';
final int postgresPort = env.databasePort ?? 5432;
final String dbUser = env.databaseUser ?? 'postgres';
final String dbPW = env.databasePassword ?? '';
final String postgresDatabaseName = env.databaseName ?? 'wrestling_scoreboard';

static final PostgresDb _singleton = PostgresDb._internal();
late psql.Connection connection;
Expand Down
Loading

0 comments on commit ae53831

Please sign in to comment.