Remove server package

This commit is contained in:
Elias Projahn 2022-05-06 13:48:49 +02:00
parent 608726f555
commit b36fe340ad
29 changed files with 0 additions and 1807 deletions

View file

@ -12,9 +12,6 @@ depend on other ones. All packages are written in [Dart](https://dart.dev).
`database` A Database of classical music. This package will be used by all
standalone Musicus applications for storing classical music metadata.
`server` A simple http server hosting a Musicus database. The server is
developed using the [Aqueduct framework](https://aqueduct.io).
`client` A client library for the Musicus server.
`common` Common building blocks for Musicus client apps. This includes shared

31
database/.gitignore vendored
View file

@ -1,31 +0,0 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# VS Code related
.vscode/
# Flutter/Dart/Pub related
**/*.g.dart
**/doc/api/
.dart_tool/
pubspec.lock
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/

View file

@ -1,7 +0,0 @@
targets:
$default:
builders:
moor_generator:
options:
generate_connect_constructor: true
use_column_name_as_json_key_when_defined_in_moor_file: false

View file

@ -1,2 +0,0 @@
export 'src/database.dart';
export 'src/info.dart';

View file

@ -1,337 +0,0 @@
import 'dart:math';
import 'package:moor/moor.dart';
import 'info.dart';
part 'database.g.dart';
final _random = Random(DateTime.now().millisecondsSinceEpoch);
int generateId() => _random.nextInt(0xFFFFFFFF);
@UseMoor(
include: {
'database.moor',
},
)
class Database extends _$Database {
static const pageSize = 25;
Database(QueryExecutor queryExecutor) : super(queryExecutor);
Database.connect(DatabaseConnection connection) : super.connect(connection);
@override
int get schemaVersion => 1;
@override
MigrationStrategy get migration => MigrationStrategy(
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
},
);
/// Get all available persons.
///
/// This will return a list of [pageSize] persons. You can get another page
/// using the [page] parameter. If a non empty [search] string is provided,
/// the persons will get filtered based on that string.
Future<List<Person>> getPersons([int page, String search]) async {
final offset = page != null ? page * pageSize : 0;
List<Person> result;
if (search == null || search.isEmpty) {
result = await allPersons(pageSize, offset).get();
} else {
result = await searchPersons('$search%', pageSize, offset).get();
}
return result;
}
/// Add [person] or replace an existing person with the same ID.
Future<void> updatePerson(Person person) async {
await into(persons).insert(
person,
mode: InsertMode.insertOrReplace,
);
}
/// Delete the person by [id].
Future<void> deletePerson(int id) async {
await (delete(persons)..where((p) => p.id.equals(id))).go();
}
/// Get all available instruments.
///
/// This will return a list of [pageSize] instruments. You can get another
/// page using the [page] parameter. If a non empty [search] string is
/// provided, the instruments will get filtered based on that string.
Future<List<Instrument>> getInstruments([int page, String search]) async {
final offset = page != null ? page * pageSize : 0;
List<Instrument> result;
if (search == null || search.isEmpty) {
result = await allInstruments(pageSize, offset).get();
} else {
result = await searchInstruments('$search%', pageSize, offset).get();
}
return result;
}
/// Add [instrument] or replace an existing one with the same ID.
Future<void> updateInstrument(Instrument instrument) async {
await into(instruments).insert(
instrument,
mode: InsertMode.insertOrReplace,
);
}
/// Delete the instrument by [id].
Future<void> deleteInstrument(int id) async {
await (delete(instruments)..where((i) => i.id.equals(id))).go();
}
/// Retrieve more information on an already queried work.
Future<WorkInfo> getWorkInfo(Work work) async {
final id = work.id;
final composers = await partComposersByWork(id).get();
composers.insert(0, await personById(work.composer).getSingle());
final instruments = await instrumentsByWork(id).get();
final List<PartInfo> parts = [];
for (final part in await partsByWork(id).get()) {
parts.add(PartInfo(
part: part,
composer: part.composer != null
? await personById(part.composer).getSingle()
: null,
instruments: await instrumentsByWorkPart(part.id).get(),
));
}
final List<WorkSection> sections = [];
for (final section in await sectionsByWork(id).get()) {
sections.add(section);
}
return WorkInfo(
work: work,
instruments: instruments,
composers: composers,
parts: parts,
sections: sections,
);
}
/// Get all available information on a work.
Future<WorkInfo> getWork(int id) async {
final work = await workById(id).getSingle();
return await getWorkInfo(work);
}
/// Get information on all works written by the person with ID [personId].
///
/// This will return a list of [pageSize] results. You can get another page
/// using the [page] parameter. If a non empty [search] string is provided,
/// the works will be filtered using that string.
Future<List<WorkInfo>> getWorks(int personId,
[int page, String search]) async {
final offset = page != null ? page * pageSize : 0;
List<Work> works;
if (search == null || search.isEmpty) {
works = await worksByComposer(personId, pageSize, offset).get();
} else {
works =
await searchWorksByComposer(personId, '$search%', pageSize, offset)
.get();
}
final List<WorkInfo> result = [];
for (final work in works) {
result.add(await getWorkInfo(work));
}
return result;
}
/// Add or replace a work and its associated data.
///
/// This will explicitly update all associated composers and instruments, even
/// if they have already existed before.
Future<void> updateWork(WorkInfo workInfo) async {
await transaction(() async {
final workId = workInfo.work.id;
// Delete old work data first. The parts, sections and instrumentations
// will be deleted automatically due to their foreign key constraints.
await deleteWork(workId);
// This will also include the composers of the work's parts.
for (final person in workInfo.composers) {
await updatePerson(person);
}
await into(works).insert(workInfo.work);
// At the moment, this will also update all provided instruments, even if
// they were already there previously.
for (final instrument in workInfo.instruments) {
await updateInstrument(instrument);
await into(instrumentations).insert(Instrumentation(
work: workId,
instrument: instrument.id,
));
}
for (final partInfo in workInfo.parts) {
final part = partInfo.part;
await into(workParts).insert(part);
for (final instrument in workInfo.instruments) {
await updateInstrument(instrument);
await into(partInstrumentations).insert(PartInstrumentation(
workPart: part.id,
instrument: instrument.id,
));
}
}
for (final section in workInfo.sections) {
await into(workSections).insert(section);
}
});
}
/// Delete the work by [id].
Future<void> deleteWork(int id) async {
// The parts and instrumentations will be deleted automatically due to
// their foreign key constraints.
await (delete(works)..where((w) => w.id.equals(id))).go();
}
/// Get all available ensembles.
///
/// This will return a list of [pageSize] ensembles. You can get another page
/// using the [page] parameter. If a non empty [search] string is provided,
/// the ensembles will get filtered based on that string.
Future<List<Ensemble>> getEnsembles([int page, String search]) async {
final offset = page != null ? page * pageSize : 0;
List<Ensemble> result;
if (search == null || search.isEmpty) {
result = await allEnsembles(pageSize, offset).get();
} else {
result = await searchEnsembles('$search%', pageSize, offset).get();
}
return result;
}
/// Add [ensemble] or replace an existing one with the same ID.
Future<void> updateEnsemble(Ensemble ensemble) async {
await into(ensembles).insert(
ensemble,
mode: InsertMode.insertOrReplace,
);
}
/// Delete the ensemble by [id].
Future<void> deleteEnsemble(int id) async {
await (delete(ensembles)..where((e) => e.id.equals(id))).go();
}
/// Add or replace a recording and its associated data.
///
/// This will explicitly also update all assoicated persons and instruments.
Future<void> updateRecording(RecordingInfo recordingInfo) async {
await transaction(() async {
final recordingId = recordingInfo.recording.id;
// Delete the old recording first. This will also delete the performances
// due to their foreign key constraint.
await deleteRecording(recordingId);
await into(recordings).insert(recordingInfo.recording);
for (final performance in recordingInfo.performances) {
if (performance.person != null) {
await updatePerson(performance.person);
}
if (performance.ensemble != null) {
await updateEnsemble(performance.ensemble);
}
if (performance.role != null) {
await updateInstrument(performance.role);
}
await into(performances).insert(Performance(
recording: recordingId,
person: performance.person?.id,
ensemble: performance.ensemble?.id,
role: performance.role?.id,
));
}
});
}
/// Retreive more information on an already queried recording.
Future<RecordingInfo> getRecordingInfo(Recording recording) async {
final id = recording.id;
final List<PerformanceInfo> performances = [];
for (final performance in await performancesByRecording(id).get()) {
performances.add(PerformanceInfo(
person: performance.person != null
? await personById(performance.person).getSingle()
: null,
ensemble: performance.ensemble != null
? await ensembleById(performance.ensemble).getSingle()
: null,
role: performance.role != null
? await instrumentById(performance.role).getSingle()
: null,
));
}
return RecordingInfo(
recording: recording,
performances: performances,
);
}
/// Get all available information on a recording.
Future<RecordingInfo> getRecording(int id) async {
final recording = await recordingById(id).getSingle();
return await getRecordingInfo(recording);
}
/// Delete a recording by [id].
Future<void> deleteRecording(int id) async {
// This will also delete the performances due to their foreign key
// constraint.
await (delete(recordings)..where((r) => r.id.equals(id))).go();
}
/// Get information on all recordings of the work with ID [workId].
///
/// This will return a list of [pageSize] recordings. You can get the other
/// pages using the [page] parameter.
Future<List<RecordingInfo>> getRecordings(int workId, [int page]) async {
final offset = page != null ? page * pageSize : 0;
final recordings = await recordingsByWork(workId, pageSize, offset).get();
final List<RecordingInfo> result = [];
for (final recording in recordings) {
result.add(await getRecordingInfo(recording));
}
return result;
}
}

View file

@ -1,139 +0,0 @@
CREATE TABLE persons (
id INTEGER NOT NULL PRIMARY KEY,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL
);
-- This represents real instruments as well as other roles that can be played
-- in a recording.
CREATE TABLE instruments (
id INTEGER NOT NULL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE works (
id INTEGER NOT NULL PRIMARY KEY,
composer INTEGER REFERENCES persons(id) ON DELETE SET NULL,
title TEXT NOT NULL
);
CREATE TABLE instrumentations (
work INTEGER NOT NULL REFERENCES works(id) ON DELETE CASCADE,
instrument INTEGER NOT NULL REFERENCES instruments(id) ON DELETE CASCADE
);
CREATE TABLE work_parts (
id INTEGER NOT NULL PRIMARY KEY,
composer INTEGER REFERENCES persons(id) ON DELETE SET NULL,
title TEXT NOT NULL,
part_of INTEGER NOT NULL REFERENCES works(id) ON DELETE CASCADE,
part_index INTEGER NOT NULL
);
CREATE TABLE part_instrumentations (
work_part INTEGER NOT NULL REFERENCES works(id) ON DELETE CASCADE,
instrument INTEGER NOT NULL REFERENCES instruments(id) ON DELETE CASCADE
);
CREATE TABLE work_sections (
id INTEGER NOT NULL PRIMARY KEY,
work INTEGER NOT NULL REFERENCES works(id) ON DELETE CASCADE,
title TEXT NOT NULL,
before_part_index INTEGER NOT NULL
);
CREATE TABLE ensembles (
id INTEGER NOT NULL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE recordings (
id INTEGER NOT NULL PRIMARY KEY,
work INTEGER REFERENCES works(id) ON DELETE SET NULL,
comment TEXT NOT NULL
);
CREATE TABLE performances (
recording INTEGER NOT NULL REFERENCES recordings(id) ON DELETE CASCADE,
person INTEGER REFERENCES persons(id) ON DELETE CASCADE,
ensemble INTEGER REFERENCES ensembles(id) ON DELETE CASCADE,
role INTEGER REFERENCES instruments(id) ON DELETE SET NULL
);
allPersons:
SELECT * FROM persons ORDER BY last_name, first_name
LIMIT :limit OFFSET :offset;
searchPersons:
SELECT * FROM persons WHERE last_name LIKE :search
ORDER BY last_name, first_name LIMIT :limit OFFSET :offset;
personById:
SELECT * FROM persons WHERE id = :id LIMIT 1;
allInstruments:
SELECT * FROM instruments ORDER BY name LIMIT :limit OFFSET :offset;
searchInstruments:
SELECT * FROM instruments WHERE name LIKE :search ORDER BY name
LIMIT :limit OFFSET :offset;
instrumentById:
SELECT * FROM instruments WHERE id = :id LIMIT 1;
workById:
SELECT * FROM works WHERE id = :id LIMIT 1;
partsByWork:
SELECT * FROM work_parts WHERE part_of = :id ORDER BY part_index;
sectionsByWork:
SELECT * FROM work_sections WHERE work = :id ORDER BY before_part_index;
worksByComposer:
SELECT DISTINCT works.* FROM works
JOIN work_parts ON work_parts.part_of = works.id
WHERE works.composer = :id OR work_parts.composer = :id
ORDER BY works.title LIMIT :limit OFFSET :offset;
searchWorksByComposer:
SELECT DISTINCT works.* FROM works
JOIN work_parts ON work_parts.part_of = works.id
WHERE (works.composer = :id OR work_parts.composer = :id)
AND works.title LIKE :search
ORDER BY works.title LIMIT :limit OFFSET :offset;
partComposersByWork:
SELECT DISTINCT persons.* FROM persons
JOIN work_parts ON work_parts.composer = persons.id
WHERE work_parts.part_of = :id;
instrumentsByWork:
SELECT instruments.* FROM instrumentations
JOIN instruments ON instrumentations.instrument = instruments.id
WHERE instrumentations.work = :workId;
instrumentsByWorkPart:
SELECT instruments.* FROM part_instrumentations
JOIN instruments ON part_instrumentations.instrument = instruments.id
WHERE part_instrumentations.work_part = :id;
allEnsembles:
SELECT * FROM ensembles ORDER BY name LIMIT :limit OFFSET :offset;
searchEnsembles:
SELECT * FROM ensembles WHERE name LIKE :search ORDER BY name
LIMIT :limit OFFSET :offset;
ensembleById:
SELECT * FROM ensembles WHERE id = :id LIMIT 1;
recordingById:
SELECT * FROM recordings WHERE id = :id;
recordingsByWork:
SELECT * FROM recordings WHERE work = :id ORDER BY id
LIMIT :limit OFFSET :offset;
performancesByRecording:
SELECT * FROM performances WHERE recording = :id;

View file

@ -1,157 +0,0 @@
import 'database.dart';
/// A bundle of all available information on a work part.
class PartInfo {
/// The work part itself.
final WorkPart part;
/// A list of instruments.
///
/// This will include the instruments, that are specific to this part.
final List<Instrument> instruments;
/// The composer of this part.
///
/// This is null, if this part doesn't have a specific composer.
final Person composer;
PartInfo({
this.part,
this.instruments,
this.composer,
});
factory PartInfo.fromJson(Map<String, dynamic> json) => PartInfo(
part: WorkPart.fromJson(json['part']),
instruments: json['instruments']
.map<Instrument>((j) => Instrument.fromJson(j))
.toList(),
composer:
json['composer'] != null ? Person.fromJson(json['composer']) : null,
);
Map<String, dynamic> toJson() => {
'part': part.toJson(),
'instruments': instruments.map((i) => i.toJson()).toList(),
'composers': composer?.toJson(),
};
}
/// A bundle information on a work.
///
/// This includes all available information except for recordings of this work.
class WorkInfo {
/// The work itself.
final Work work;
/// A list of instruments.
///
/// This will not the include the instruments, that are specific to the work
/// parts.
final List<Instrument> instruments;
/// A list of persons, which will include all part composers.
final List<Person> composers;
/// All available information on the work parts.
final List<PartInfo> parts;
/// The sections of this work.
final List<WorkSection> sections;
WorkInfo({
this.work,
this.instruments,
this.composers,
this.parts,
this.sections,
});
factory WorkInfo.fromJson(Map<String, dynamic> json) => WorkInfo(
work: Work.fromJson(json['work']),
instruments: json['instruments']
.map<Instrument>((j) => Instrument.fromJson(j))
.toList(),
composers:
json['composers'].map<Person>((j) => Person.fromJson(j)).toList(),
parts:
json['parts'].map<PartInfo>((j) => PartInfo.fromJson(j)).toList(),
sections: json['sections']
.map<WorkSection>((j) => WorkSection.fromJson(j))
.toList(),
);
Map<String, dynamic> toJson() => {
'work': work.toJson(),
'instruments': instruments.map((i) => i.toJson()).toList(),
'composers': composers.map((c) => c.toJson()).toList(),
'parts': parts.map((c) => c.toJson()).toList(),
'sections': sections.map((s) => s.toJson()).toList(),
};
}
/// All available information on a performance within a recording.
class PerformanceInfo {
/// The performing person.
///
/// This will be null, if this is an ensemble.
final Person person;
/// The performing ensemble.
///
/// This will be null, if this is a person.
final Ensemble ensemble;
/// The instrument/role or null.
final Instrument role;
PerformanceInfo({
this.person,
this.ensemble,
this.role,
});
factory PerformanceInfo.fromJson(Map<String, dynamic> json) =>
PerformanceInfo(
person: json['person'] != null ? Person.fromJson(json['person']) : null,
ensemble: json['ensemble'] != null
? Ensemble.fromJson(json['ensemble'])
: null,
role: json['role'] != null ? Instrument.fromJson(json['role']) : null,
);
Map<String, dynamic> toJson() => {
'person': person?.toJson(),
'ensemble': ensemble?.toJson(),
'role': role?.toJson(),
};
}
/// All available information on a recording.
///
/// This doesn't include the recorded work, because probably it's already
/// available.
class RecordingInfo {
/// The recording itself.
final Recording recording;
/// Information on the performances within this recording.
final List<PerformanceInfo> performances;
RecordingInfo({
this.recording,
this.performances,
});
factory RecordingInfo.fromJson(Map<String, dynamic> json) => RecordingInfo(
recording: Recording.fromJson(json['recording']),
performances: json['performances']
.map<PerformanceInfo>((j) => PerformanceInfo.fromJson(j))
.toList(),
);
Map<String, dynamic> toJson() => {
'recording': recording.toJson(),
'performances': performances.map((p) => p.toJson()).toList(),
};
}

View file

@ -1,14 +0,0 @@
name: musicus_database
description: A database for classical music.
version: 0.0.1
environment:
sdk: ">=2.3.0 <3.0.0"
dependencies:
moor:
moor_ffi:
dev_dependencies:
build_runner:
moor_generator:

31
server/.gitignore vendored
View file

@ -1,31 +0,0 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# VS Code related
.vscode/
# Flutter/Dart/Pub related
**/*.g.dart
**/doc/api/
.dart_tool/
pubspec.lock
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/

View file

@ -1,207 +0,0 @@
# Musicus server
A server hosting a shared Musicus database.
## Introduction
A Musicus server publishes the contents of a Musicus database via a simple
HTTP API. Registered users may additionally add entities to the database and
some users maintain the database by editing or deleting entities.
## API documentation
Important note: The Musicus API is not stable yet. This means, that there will
probably be breaking changes without any kind of versioning. At the moment,
this documentation while mostly describing the API as it works today is
nothing more than a draft.
### Retrieving information
All entities are available to the public without authentication. The response
will have the content type `application/json` and the body will contain either
a list of JSON objects or just a JSON object. The server handles `GET` requests
at the following routes:
| Route | Result | Pagination | Search |
| ------------------------ | ----------------------------------------------- | ---------- | ------ |
| `/persons` | A list of persons | Yes | Yes |
| `/persons/{id}` | One person by its ID or error `404` | No | No |
| `/persons/{id}/works` | A list of works by the person or error `404` | Yes | Yes |
| `/instruments` | A list of instruments | Yes | Yes |
| `/instruments/{id}` | One instrument by its ID or error `404` | No | No |
| `/works/{id}` | One work by its ID or error `404` | No | No |
| `/works/{id}/recordings` | A list of recordings of the work or error `404` | Yes | No |
| `/ensembles` | A list of ensembles | Yes | Yes |
| `/ensembles/{id}` | One ensemble by its ID or error `404` | No | No |
| `/recordings/{id}` | One recording by its ID or error `404` | No | No |
#### Pagination
Routes that use pagination for their result will always limit the result to a
constant amount of entities. You can get other pages using the `?p={page}`
query parameter.
#### Search
Routes supporting search can be supplied with a search string using the
`?s={search}` query parameter.
### Authentication
Users that would like to contribute to the information hosted by the server
will need to authenticate.
#### Registration
For registration, the server handles `POST` requests to `/account/register`.
The request body has to be valid JSON and have the following form.
```json
{
"username": "username",
"email": "optional@email.address",
"password": "password"
}
```
The following errors may occur:
| Error code | Explanation |
| ---------- | ---------------------------------------- |
| `400` | The body was malformed. |
| `409` | The username is already taken. |
| `415` | Content type was not `application/json`. |
#### Login
All protected resources will check for a valid token within the authorization
header of the request. The client can get a token by sending a `POST` request
to `/account/login`. The request body should contain a valid JSON object of the
following form:
```json
{
"username": "username",
"password": "password"
}
```
If the operation was successful, the token will be returned in the response
body as a single string with the content type `text/plain`.
The following errors may occur:
| Error code | Explanation |
| ---------- | ---------------------------------------- |
| `400` | The body was malformed. |
| `401` | Login failed |
| `415` | Content type was not `application/json`. |
#### Authorization
When accessing a protected resource, the client should include a authorization
header with the token retrieved when logging in. The authorization type should
be `Bearer`. If the provided token is valid and the user is authorized to
perform the requested action, the expected response for the route beeing
accessed will be returned.
The following errors may occur:
| Error code | Explanation |
| ---------- | -------------------------------------------------------- |
| `400` | The authorization header was malformed. |
| `401` | The provided token is invalid. |
| `403` | The user is not allowed to perform the requested action. |
#### Retrieving account details
The client can retrieve the current account details for a user using a `GET`
request to `/account/details`. The user has to be logged in. The returned body
will have the content type `application/json` and the following format:
```json
{
"email": "optional@email.address"
}
```
#### Changing account details
To change the email address or password for an existing user, the client may
send a `POST` request to `/account/details`. The content type has to be
`application/json` and the body should contain a valid JSON object in the
following form:
```json
{
"username": "username",
"password": "old password",
"newEmail": "optional@email.address",
"newPassword": "new password"
}
```
The `newEmail` and `newPassword` parameters both can be left out or set to null
to indicate that they remain unchanged. `username` and `password` have to be
provided. If the user doesn't exist or the old password was wrong, an error
`403` will be returned.
#### Deleting an account
To delete an existing account, the client may send a `POST` request to
`/account/delete`. The content type has to be `application/json` and the body
should contain a valid JSON object in the following form:
```json
{
"username": "username",
"password": "password"
}
```
If the user doesn't exist or the password was wrong, an error `403` will be
returned.
### Adding new entities
To be able to add new entities, the user has to be authenticated and authorized
to do so. By default, this is the case for newly registered users. The content
type should be `application/json` and the body should contain a valid JSON
object matching the specific resource. The entity ID should be generated on
the client side to facilitate offline usage. This means, that entity creation
will be handled using `PUT` requests to the following routes:
- `/persons/{id}`
- `/instruments/{id}`
- `/works/{id}`
- `/ensembles/{id}`
- `/recordings/{id}`
The following errors may occur:
| Error code | Explanation |
| ---------- | ---------------------------------------- |
| `400` | The body was malformed. |
| `415` | Content type was not `application/json`. |
### Editing existing entities
To be able to edit existing entities, the user has to be authenticated and
authorized to do so. By default, newly registered users are not allowed to edit
entities. The interface is exactly the same as the one for adding new entities.
### Deleting entities
To be able to delete existing entities, the user has to be authenticated and
authorized to do so. By default, newly registered users are not allowed to
delete entities. The following routes handle `DELETE` requests for deleting
entities:
- `/persons/{id}`
- `/instruments/{id}`
- `/works/{id}`
- `/ensembles/{id}`
- `/recordings/{id}`
If the entity doesn't exist, an error `404` will be returned.

View file

@ -1,20 +0,0 @@
import 'package:aqueduct/aqueduct.dart';
import 'package:musicus_server/musicus_server.dart';
Future<void> main() async {
final configFilePath = 'config.yaml';
final config = MusicusServerConfiguration(configFilePath);
final server = Application<MusicusServer>()
..options.configurationFilePath = configFilePath
..options.address = config.host
..options.port = config.port;
await server.start(
consoleLogging: true,
);
print('Database: ${config.dbPath ?? 'memory'}');
print('Server database: ${config.serverDbPath ?? 'memory'}');
print('Listening on ${config.host}:${config.port}');
}

View file

@ -1,4 +0,0 @@
# A dbPath and serverDbPath of null means that we want in-memory databases.
host: localhost
port: 1833
secret: vulnerable

View file

@ -1,5 +0,0 @@
host: localhost
port: 1833
secret: vulnerable
dbPath: db.sqlite
serverDbPath: server.sqlite

View file

@ -1,2 +0,0 @@
export 'src/configuration.dart';
export 'src/server.dart';

View file

@ -1,293 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:aqueduct/aqueduct.dart';
import 'package:corsac_jwt/corsac_jwt.dart';
import 'compute.dart';
import 'crypt.dart';
import 'database.dart';
/// Information on the user making the request.
extension AuthorizationInfo on Request {
/// The username of the logged in user.
///
/// If this is a non null value, the user was authenticated.
String get username => this.attachments['username'];
set username(String value) => this.attachments['username'] = value;
/// Whether the user may create new resources.
///
/// This can only be true if the user was authenticated.
bool get mayUpload => this.attachments['mayUpload'] ?? false;
set mayUpload(bool value) => this.attachments['mayUpload'] = value;
/// Whether the user may edit existing resources.
///
/// This can only be true if the user was authenticated.
bool get mayEdit => this.attachments['mayEdit'] ?? false;
set mayEdit(bool value) => this.attachments['mayEdit'] = value;
/// Whether the user may delete resources.
///
/// This can only be true if the user was authenticated.
bool get mayDelete => this.attachments['mayDelete'] ?? false;
set mayDelete(bool value) => this.attachments['mayDelete'] = value;
}
/// Endpoint controller for user registration.
///
/// This expects a POST request with a JSON body representing a [RequestUser].
class RegisterController extends Controller {
final ServerDatabase db;
RegisterController(this.db);
@override
Future<Response> handle(Request request) async {
if (request.method == 'POST') {
final json = await request.body.decode<Map<String, dynamic>>();
final String username = json['username'];
final String email = json['email'];
final String password = json['password'];
// Check if we already have a user with that name.
final existingUser = await db.getUser(username);
if (existingUser != null) {
// Returning something different than 200 here has the security
// implication that an attacker can check for existing user names. At
// the moment, I don't see any alternatives, because we don't use email
// addresses for identification. The client needs to know, whether the
// user name is already given.
return Response.conflict();
} else {
// This will take a long time, so we run it in a new isolate.
final result = await compute(Crypt.hashPassword, password);
db.updateUser(User(
name: username,
email: email,
salt: result.salt,
hash: result.hash,
mayUpload: true,
mayEdit: false,
mayDelete: false,
));
return Response.ok(null);
}
} else {
return Response(HttpStatus.methodNotAllowed, null, null);
}
}
}
/// Endpoint controller for user login.
///
/// This expects a POST request with a JSON body representing a [RequestUser].
class LoginController extends Controller {
final ServerDatabase db;
/// The secret that will be used for signing the token.
final String secret;
final JWTHmacSha256Signer _signer;
LoginController(this.db, this.secret) : _signer = JWTHmacSha256Signer(secret);
@override
Future<Response> handle(Request request) async {
if (request.method == 'POST') {
final json = await request.body.decode<Map<String, dynamic>>();
final String username = json['username'];
final String password = json['password'];
final user = await db.getUser(username);
if (user != null) {
// We check the password in a new isolate, because this can take a long
// time.
if (await compute(
Crypt.checkPassword,
CheckPasswordRequest(
password: password,
salt: user.salt,
hash: user.hash,
),
)) {
final builder = JWTBuilder()
..expiresAt = DateTime.now().add(Duration(minutes: 30))
..setClaim('user', username);
final token = builder.getSignedToken(_signer).toString();
return Response.ok(token, headers: {'Content-Type': 'text/plain'});
}
}
return Response.unauthorized();
}
return Response(HttpStatus.methodNotAllowed, null, null);
}
}
/// An endpoint controller for retrieving and changing account details.
class AccountDetailsController extends Controller {
final ServerDatabase db;
AccountDetailsController(this.db);
@override
Future<Response> handle(Request request) async {
if (request.method == 'GET') {
if (request.username != null) {
final user = await db.getUser(request.username);
return Response.ok({
'email': user.email,
});
} else {
return Response.forbidden();
}
} else if (request.method == 'POST') {
final json = await request.body.decode<Map<String, dynamic>>();
final String username = json['username'];
final String password = json['password'];
final String newEmail = json['newEmail'];
final String newPassword = json['newPassword'];
final user = await db.getUser(username);
// Check whether the user exists and the password was right.
if (user != null &&
await compute(
Crypt.checkPassword,
CheckPasswordRequest(
password: password,
salt: user.salt,
hash: user.hash,
),
)) {
HashPasswordResult hashResult;
if (newPassword != null) {
hashResult = await compute(Crypt.hashPassword, newPassword);
} else {
hashResult = HashPasswordResult(
hash: user.hash,
salt: user.salt,
);
}
db.updateUser(User(
name: username,
email: newEmail ?? user.email,
salt: hashResult.salt,
hash: hashResult.hash,
mayUpload: user.mayUpload,
mayEdit: user.mayEdit,
mayDelete: user.mayDelete,
));
return Response.ok(null);
} else {
return Response.forbidden();
}
} else {
return Response(HttpStatus.methodNotAllowed, null, null);
}
}
}
/// An endpoint controller for deleting an account.
class AccountDeleteController extends Controller {
final ServerDatabase db;
AccountDeleteController(this.db);
@override
Future<Response> handle(Request request) async {
if (request.method == 'POST') {
final json = await request.body.decode<Map<String, dynamic>>();
final String username = json['username'];
final String password = json['password'];
final user = await db.getUser(username);
// Check whether the user exists and the password was right.
if (user != null &&
await compute(
Crypt.checkPassword,
CheckPasswordRequest(
password: password,
salt: user.salt,
hash: user.hash,
),
)) {
await db.deleteUser(username);
return Response.ok(null);
} else {
return Response.forbidden();
}
} else {
return Response(HttpStatus.methodNotAllowed, null, null);
}
}
}
/// Middleware for checking authorization.
///
/// This will set the fields defined in [AuthorizationInfo] on this request
/// according to the provided access token.
class AuthorizationController extends Controller {
final ServerDatabase db;
/// The secret that was used to sign the token.
final String secret;
final JWTHmacSha256Signer _signer;
AuthorizationController(this.db, this.secret)
: _signer = JWTHmacSha256Signer(secret);
@override
FutureOr<RequestOrResponse> handle(Request request) async {
final authHeaderValue =
request.raw.headers.value(HttpHeaders.authorizationHeader);
if (authHeaderValue != null) {
final authHeaderParts = authHeaderValue.split(' ');
if (authHeaderParts.length == 2 && authHeaderParts[0] == 'Bearer') {
final jwt = JWT.parse(authHeaderParts[1]);
/// The JWTValidator will automatically use the current time. An empty
/// result will mean that the token is valid and its signature was
/// verified.
if (JWTValidator().validate(jwt, signer: _signer).isEmpty) {
final user = await db.getUser(jwt.claims['user']);
if (user != null) {
request.username = user.name;
request.mayUpload = user.mayUpload;
request.mayEdit = user.mayEdit;
request.mayDelete = user.mayDelete;
return request;
} else {
return Response.unauthorized();
}
} else {
return Response.unauthorized();
}
} else {
return Response.badRequest();
}
} else {
return request;
}
}
}

View file

@ -1,15 +0,0 @@
import 'package:aqueduct/aqueduct.dart';
import 'package:musicus_database/musicus_database.dart';
class CompositionsController extends ResourceController {
final Database db;
CompositionsController(this.db);
@Operation.get('id')
Future<Response> getWorks(@Bind.path('id') int id,
{@Bind.query('p') int page, @Bind.query('s') String search}) async {
final works = await db.getWorks(id, page, search);
return Response.ok(works);
}
}

View file

@ -1,52 +0,0 @@
import 'dart:isolate';
import 'package:meta/meta.dart';
/// This function will run within the new isolate.
void _isolateEntrypoint<T, S>(_ComputeRequest<T, S> request) {
final result = request.compute();
request.sendPort.send(result);
}
/// Bundle of information to pass to the isolate.
class _ComputeRequest<T, S> {
/// The function to call.
T Function(S parameter) function;
/// The parameter to pass to the function.
S parameter;
/// The port through which the result will be sent.
SendPort sendPort;
_ComputeRequest({
@required this.function,
@required this.parameter,
@required this.sendPort,
});
/// Call [function] with [parameter] and return the result.
///
/// This function exists to avoid type errors within the isolate.
T compute() => function(parameter);
}
/// Call a function in a new isolate and await the result.
///
/// The function has to be a static function. If the result is not a primitive
/// value or a list or map of such, this won't work
/// (see https://api.dart.dev/stable/2.8.1/dart-isolate/SendPort/send.html).
Future<T> compute<T, S>(T Function(S parameter) function, S parameter) async {
final receivePort = ReceivePort();
Isolate.spawn(
_isolateEntrypoint,
_ComputeRequest<T, S>(
function: function,
parameter: parameter,
sendPort: receivePort.sendPort,
),
);
return await receivePort.first as T;
}

View file

@ -1,17 +0,0 @@
import 'dart:io';
import 'package:aqueduct/aqueduct.dart';
class MusicusServerConfiguration extends Configuration {
MusicusServerConfiguration(String fileName) : super.fromFile(File(fileName));
String host;
int port;
String secret;
@optionalConfiguration
String dbPath;
@optionalConfiguration
String serverDbPath;
}

View file

@ -1,62 +0,0 @@
import 'dart:convert';
import 'dart:math';
import 'package:meta/meta.dart';
import 'package:steel_crypt/steel_crypt.dart';
/// Result of [hashPassword].
class HashPasswordResult {
/// The computed hash.
final String hash;
/// A randomly generated string.
final String salt;
HashPasswordResult({
@required this.hash,
@required this.salt,
});
}
/// Parameters for [checkPassword].
class CheckPasswordRequest {
/// The password to check.
final String password;
/// The salt that was used for computing the hash.
final String salt;
/// The hash value to check against.
final String hash;
CheckPasswordRequest({
@required this.password,
@required this.salt,
@required this.hash,
});
}
/// Methods for handling passwords.
class Crypt {
static final _crypt = PassCrypt.pbkdf2(hmac: HmacHash.Sha_512);
static final _rand = Random.secure();
/// Compute a hash for a password.
///
/// The result will contain the hash and a randomly generated salt.
static HashPasswordResult hashPassword(String password) {
final bytes = List.generate(32, (i) => _rand.nextInt(256));
final salt = base64UrlEncode(bytes);
final hash = _crypt.hashPass(salt, password);
return HashPasswordResult(
hash: hash,
salt: salt,
);
}
/// Check whether a password matches a hash value.
static bool checkPassword(CheckPasswordRequest request) {
return _crypt.checkPassKey(request.salt, request.password, request.hash);
}
}

View file

@ -1,23 +0,0 @@
import 'package:moor/moor.dart';
part 'database.g.dart';
@UseMoor(include: {'database.moor'})
class ServerDatabase extends _$ServerDatabase {
@override
final schemaVersion = 0;
ServerDatabase(QueryExecutor e) : super(e);
Future<User> getUser(String name) async {
return await (select(users)..where((u) => u.name.equals(name))).getSingle();
}
Future<void> updateUser(User user) async {
await into(users).insert(user, mode: InsertMode.insertOrReplace);
}
Future<void> deleteUser(String name) async {
await (delete(users)..where((u) => u.name.equals(name))).go();
}
}

View file

@ -1,9 +0,0 @@
CREATE TABLE users (
name TEXT PRIMARY KEY,
email TEXT,
salt TEXT NOT NULL,
hash TEXT NOT NULL,
may_upload BOOLEAN NOT NULL DEFAULT TRUE,
may_edit BOOLEAN NOT NULL DEFAULT FALSE,
may_delete BOOLEAN NOT NULL DEFAULT FALSE
);

View file

@ -1,59 +0,0 @@
import 'package:aqueduct/aqueduct.dart';
import 'package:musicus_database/musicus_database.dart';
import 'auth.dart';
class EnsemblesController extends ResourceController {
final Database db;
EnsemblesController(this.db);
@Operation.get()
Future<Response> getEnsembles(
{@Bind.query('p') int page, @Bind.query('s') String search}) async {
final ensembles = await db.getEnsembles(page, search);
return Response.ok(ensembles);
}
@Operation.get('id')
Future<Response> getEnsemble(@Bind.path('id') int id) async {
final ensemble = await db.ensembleById(id).getSingle();
if (ensemble != null) {
return Response.ok(ensemble);
} else {
return Response.notFound();
}
}
@Operation.put('id')
Future<Response> putEnsemble(
@Bind.path('id') int id, @Bind.body() Map<String, dynamic> json) async {
if (await db.ensembleById(id).getSingle() != null) {
if (!request.mayEdit) {
return Response.forbidden();
}
} else {
if (!request.mayUpload) {
return Response.forbidden();
}
}
final ensemble = Ensemble.fromJson(json).copyWith(
id: id,
);
await db.updateEnsemble(ensemble);
return Response.ok(null);
}
@Operation.delete('id')
Future<Response> deleteEnsemble(@Bind.path('id') int id) async {
if (!request.mayDelete) {
return Response.forbidden();
}
await db.deleteEnsemble(id);
return Response.ok(null);
}
}

View file

@ -1,59 +0,0 @@
import 'package:aqueduct/aqueduct.dart';
import 'package:musicus_database/musicus_database.dart';
import 'auth.dart';
class InstrumentsController extends ResourceController {
final Database db;
InstrumentsController(this.db);
@Operation.get()
Future<Response> getInstruments(
{@Bind.query('p') int page, @Bind.query('s') String search}) async {
final instruments = await db.getInstruments(page, search);
return Response.ok(instruments);
}
@Operation.get('id')
Future<Response> getInstrument(@Bind.path('id') int id) async {
final instrument = await db.instrumentById(id).getSingle();
if (instrument != null) {
return Response.ok(instrument);
} else {
return Response.notFound();
}
}
@Operation.put('id')
Future<Response> putInstrument(
@Bind.path('id') int id, @Bind.body() Map<String, dynamic> json) async {
if (await db.instrumentById(id).getSingle() != null) {
if (!request.mayEdit) {
return Response.forbidden();
}
} else {
if (!request.mayUpload) {
return Response.forbidden();
}
}
final instrument = Instrument.fromJson(json).copyWith(
id: id,
);
await db.updateInstrument(instrument);
return Response.ok(null);
}
@Operation.delete('id')
Future<Response> deleteInstrument(@Bind.path('id') int id) async {
if (!request.mayDelete) {
return Response.forbidden();
}
await db.deleteInstrument(id);
return Response.ok(null);
}
}

View file

@ -1,59 +0,0 @@
import 'package:aqueduct/aqueduct.dart';
import 'package:musicus_database/musicus_database.dart';
import 'auth.dart';
class PersonsController extends ResourceController {
final Database db;
PersonsController(this.db);
@Operation.get()
Future<Response> getPersons(
{@Bind.query('p') int page, @Bind.query('s') String search}) async {
final persons = await db.getPersons(page, search);
return Response.ok(persons);
}
@Operation.get('id')
Future<Response> getPerson(@Bind.path('id') int id) async {
final person = await db.personById(id).getSingle();
if (person != null) {
return Response.ok(person);
} else {
return Response.notFound();
}
}
@Operation.put('id')
Future<Response> putPerson(
@Bind.path('id') int id, @Bind.body() Map<String, dynamic> json) async {
if (await db.personById(id).getSingle() != null) {
if (!request.mayEdit) {
return Response.forbidden();
}
} else {
if (!request.mayUpload) {
return Response.forbidden();
}
}
final person = Person.fromJson(json).copyWith(
id: id,
);
await db.updatePerson(person);
return Response.ok(null);
}
@Operation.delete('id')
Future<Response> deletePerson(@Bind.path('id') int id) async {
if (!request.mayDelete) {
return Response.forbidden();
}
await db.deletePerson(id);
return Response.ok(null);
}
}

View file

@ -1,49 +0,0 @@
import 'package:aqueduct/aqueduct.dart';
import 'package:musicus_database/musicus_database.dart';
import 'auth.dart';
class RecordingsController extends ResourceController {
final Database db;
RecordingsController(this.db);
@Operation.get('id')
Future<Response> getRecording(@Bind.path('id') int id) async {
final recording = await db.getRecording(id);
if (recording != null) {
return Response.ok(recording);
} else {
return Response.notFound();
}
}
@Operation.put('id')
Future<Response> putRecording(
@Bind.path('id') int id, @Bind.body() Map<String, dynamic> json) async {
if (await db.recordingById(id).getSingle() != null) {
if (!request.mayEdit) {
return Response.forbidden();
}
} else {
if (!request.mayUpload) {
return Response.forbidden();
}
}
final recordingInfo = RecordingInfo.fromJson(json);
await db.updateRecording(recordingInfo);
return Response.ok(null);
}
@Operation.delete('id')
Future<Response> deleteRecording(@Bind.path('id') int id) async {
if (!request.mayDelete) {
return Response.forbidden();
}
await db.deleteRecording(id);
return Response.ok(null);
}
}

View file

@ -1,67 +0,0 @@
import 'dart:io';
import 'package:aqueduct/aqueduct.dart';
import 'package:moor_ffi/moor_ffi.dart';
import 'package:musicus_database/musicus_database.dart';
import 'auth.dart';
import 'compositions.dart';
import 'configuration.dart';
import 'database.dart';
import 'ensembles.dart';
import 'instruments.dart';
import 'persons.dart';
import 'recordings.dart';
import 'works.dart';
import 'work_recordings.dart';
class MusicusServer extends ApplicationChannel {
Database db;
ServerDatabase serverDb;
String secret;
@override
Future<void> prepare() async {
final config = MusicusServerConfiguration(options.configurationFilePath);
if (config.dbPath != null) {
db = Database(VmDatabase(File(config.dbPath)));
} else {
db = Database(VmDatabase.memory());
}
if (config.serverDbPath != null) {
serverDb = ServerDatabase(VmDatabase(File(config.serverDbPath)));
} else {
serverDb = ServerDatabase(VmDatabase.memory());
}
secret = config.secret;
}
@override
Controller get entryPoint => Router()
..route('/account/register').link(() => RegisterController(serverDb))
..route('/account/details')
.link(() => AuthorizationController(serverDb, secret))
.link(() => AccountDetailsController(serverDb))
..route('/account/delete').link(() => AccountDeleteController(serverDb))
..route('/account/login').link(() => LoginController(serverDb, secret))
..route('/persons/[:id]')
.link(() => AuthorizationController(serverDb, secret))
.link(() => PersonsController(db))
..route('/persons/:id/works').link(() => CompositionsController(db))
..route('/instruments/[:id]')
.link(() => AuthorizationController(serverDb, secret))
.link(() => InstrumentsController(db))
..route('/works/:id')
.link(() => AuthorizationController(serverDb, secret))
.link(() => WorksController(db))
..route('/works/:id/recordings').link(() => WorkRecordingsController(db))
..route('/ensembles/[:id]')
.link(() => AuthorizationController(serverDb, secret))
.link(() => EnsemblesController(db))
..route('/recordings/:id')
.link(() => AuthorizationController(serverDb, secret))
.link(() => RecordingsController(db));
}

View file

@ -1,15 +0,0 @@
import 'package:aqueduct/aqueduct.dart';
import 'package:musicus_database/musicus_database.dart';
class WorkRecordingsController extends ResourceController {
final Database db;
WorkRecordingsController(this.db);
@Operation.get('id')
Future<Response> getRecordings(@Bind.path('id') int id,
{@Bind.query('p') int page}) async {
final recordings = await db.getRecordings(id, page);
return Response.ok(recordings);
}
}

View file

@ -1,49 +0,0 @@
import 'package:aqueduct/aqueduct.dart';
import 'package:musicus_database/musicus_database.dart';
import 'auth.dart';
class WorksController extends ResourceController {
final Database db;
WorksController(this.db);
@Operation.get('id')
Future<Response> getWork(@Bind.path('id') int id) async {
final work = await db.getWork(id);
if (work != null) {
return Response.ok(work);
} else {
return Response.notFound();
}
}
@Operation.put('id')
Future<Response> putWork(
@Bind.path('id') int id, @Bind.body() Map<String, dynamic> json) async {
if (await db.workById(id).getSingle() != null) {
if (!request.mayEdit) {
return Response.forbidden();
}
} else {
if (!request.mayUpload) {
return Response.forbidden();
}
}
final workInfo = WorkInfo.fromJson(json);
await db.updateWork(workInfo);
return Response.ok(null);
}
@Operation.delete('id')
Future<Response> deleteWork(@Bind.path('id') int id) async {
if (!request.mayDelete) {
return Response.forbidden();
}
await db.deleteWork(id);
return Response.ok(null);
}
}

View file

@ -1,20 +0,0 @@
name: musicus_server
description: A server hosting a Musicus database.
version: 0.0.1
environment:
sdk: ">=2.6.0 <3.0.0"
dependencies:
aqueduct:
corsac_jwt:
meta:
moor: ^3.0.2
moor_ffi: ^0.5.0
musicus_database:
path: ../database
steel_crypt: ^2.0.3
dev_dependencies:
build_runner:
moor_generator: ^3.0.0