Compare commits

...

43 Commits

Author SHA1 Message Date
fa43517b16 Release 0.15.4 2024-07-31 15:47:09 +03:00
77e99439e7 chore: fix file formatting 2024-07-31 15:46:56 +03:00
c9eb311cfe bug: remove unreachable code 2024-07-31 15:46:56 +03:00
d662e303c8 feat: xivgear demo support 2024-07-31 15:41:10 +03:00
1c8aaea712 Release 0.15.1 2024-07-22 14:00:59 +03:00
3c8e5f8da8 fix: read itemcost correctly 2024-07-22 13:58:26 +03:00
0bcda3233e feat: dependencies bump- 2024-07-03 13:36:36 +03:00
c4be6f12f1 feat: VPR and PCT support 2024-07-03 13:33:44 +03:00
f3535f6e16 bump libraries 2023-10-20 13:32:55 +03:00
bdf413d494 Release 0.14.0 2022-09-08 03:23:04 +03:00
7a1a73592e bump dependencies 2022-09-08 03:21:14 +03:00
b1ac894ccf add action button to suggest table
Also replace functions with lambdas
2022-07-15 14:15:10 +03:00
6023e86570 Release 0.13.6 2022-06-23 04:41:51 +03:00
a4ab1e49be reset bis links for empty strings 2022-06-23 04:39:21 +03:00
cb99486f8a Release 0.13.5 2022-06-23 04:20:31 +03:00
0e8b95d0dd use strict validator on input strings via api (#15)
It has been reported that that views are vulnerable for XSS because of
missing escaping (or validation). Instead of playing with conversion
from/to escaped/unescaped strings lets just forbid characters via api

This commit includes migration for postgres, sqlite migration is still
missing which will make it impossible to load pages for those parties.

This commit also includes several fixes:
* The issue when empty party could not be loaded
* The issue when link biis is not appllied after editing
* The issue when incorrect bis link has been saved
* The issue when empty password could be applied via api
* The issue when error message is not displayed at the index page

This commit also updates dependencies
2022-06-23 04:19:09 +03:00
118d8faf6b Release 0.13.4 2022-01-31 04:33:33 +03:00
448880ed91 add version to footer 2022-01-31 04:31:45 +03:00
ed3cdd62bd styling 2022-01-31 04:09:17 +03:00
88617eccdf Release 0.13.3 2022-01-23 04:46:44 +03:00
ccbf581332 add more tests
* also make auth provider more powerful
2022-01-23 04:34:39 +03:00
0ab9162cb5 Release 0.13.2 2022-01-22 00:02:23 +03:00
d3018998cd change PUT to POST for party creation request 2022-01-22 00:00:37 +03:00
d4553b2e50 Release 0.13.1 2022-01-21 03:08:34 +03:00
8496d105c0 fix auth generation and blocked https->http requests 2022-01-21 03:06:51 +03:00
ec2cfaea38 Release 0.13.0 2022-01-21 02:31:55 +03:00
963e84f792 api docs review 2022-01-21 02:30:34 +03:00
feea01a47e Release 0.12.2 2022-01-20 00:53:32 +03:00
fcacd9f15c user friednly is required table 2022-01-19 15:09:45 +03:00
b2256784dd move header buttons into one row 2022-01-19 13:42:43 +03:00
fee87ddbc8 more typed actors 2022-01-19 12:19:55 +03:00
dc882b74bf move modals to form validation 2022-01-19 03:04:59 +03:00
7a6cd84ce3 Release 0.12.1 2022-01-17 22:35:50 +03:00
33b750123d disable covreport coz it breaks the dist 2022-01-17 22:34:26 +03:00
d049238dcf Release 0.12.0 2022-01-17 22:28:13 +03:00
5d72852420 add status endpoint 2022-01-17 22:26:48 +03:00
78a00e2cab sbt improvelemnts 2022-01-17 12:17:39 +03:00
786c3d7d48 Release 0.11.1 2022-01-17 05:21:11 +03:00
8a1d99b319 change sorting order 2022-01-17 05:19:56 +03:00
ac0e0ac899 Release 0.11.0 2022-01-17 05:13:16 +03:00
e88c9d51b0 update description 2022-01-17 05:12:11 +03:00
ced781bba2 migrate to anorm
I'm tired of ORM and would like to write clear sql requests. The
following wrappers were checked:
* doobie - cats api which is useless in this project
* scalike - can't work with sqlite at all
* anorm - awful api
* something also

Anorm fits more than any other my criteria so I migrated to it with
native hikaricp usage
2022-01-17 05:10:01 +03:00
012cdd2d8b log exceptions for database requests 2022-01-16 15:15:48 +03:00
123 changed files with 2172 additions and 1151 deletions

View File

@ -25,7 +25,7 @@ jobs:
uses: actions/setup-java@v2 uses: actions/setup-java@v2
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 18
- name: create dist - name: create dist
run: make dist run: make dist
- name: release - name: release

View File

@ -17,6 +17,6 @@ jobs:
uses: actions/setup-java@v2 uses: actions/setup-java@v2
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 18
- name: run tests - name: run tests
run: make tests run: make tests

1
.gitignore vendored
View File

@ -75,6 +75,7 @@ lib_managed/
src_managed/ src_managed/
project/boot/ project/boot/
project/plugins/project/ project/plugins/project/
.bsp/
# Scala-IDE specific # Scala-IDE specific
.scala_dependencies .scala_dependencies

View File

@ -22,10 +22,10 @@ from the extracted archive root.
## Web service ## Web service
REST API documentation is available at `http://0.0.0.0:8000/swagger`. HTML representation is available at `http://0.0.0.0:8000`. REST API documentation is available at `http://0.0.0.0:8000/api-docs`. HTML representation is available at `http://0.0.0.0:8000`.
*Note*: host and port depend on configuration settings. *Note*: host and port depend on configuration settings.
## Public service ## Public service
There is also public service which is available at http://ffxivbis.arcanis.me. There is also public service which is available at https://ffxivbis.arcanis.me.

View File

@ -1,3 +0,0 @@
* [x] items improvements
* [x] multiple parties support
* [ ] pretty UI

View File

@ -1,16 +1,9 @@
organization := "me.arcanis"
name := "ffxivbis" name := "ffxivbis"
scalaVersion := "2.13.6" scalaVersion := "2.13.12"
scalacOptions ++= Seq("-deprecation", "-feature") scalacOptions ++= Seq("-deprecation", "-feature")
enablePlugins(JavaAppPackaging) enablePlugins(JavaAppPackaging)
assemblyMergeStrategy in assembly := {
case PathList("META-INF", xs @ _*) => MergeStrategy.discard
case "application.conf" => MergeStrategy.concat
case "module-info.class" => MergeStrategy.first
case x =>
val oldStrategy = (assemblyMergeStrategy in assembly).value
oldStrategy(x)
}

View File

@ -1,30 +1,29 @@
val AkkaVersion = "2.6.17" val AkkaVersion = "2.8.6"
val AkkaHttpVersion = "10.2.7" val AkkaHttpVersion = "10.5.3"
val ScalaTestVersion = "3.2.10" val ScalaTestVersion = "3.2.19"
val SlickVersion = "3.3.3"
libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.9" libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.5.6"
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.4" libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.5"
libraryDependencies += "com.typesafe.akka" %% "akka-http" % AkkaHttpVersion libraryDependencies += "com.typesafe.akka" %% "akka-http" % AkkaHttpVersion
libraryDependencies += "com.typesafe.akka" %% "akka-http-spray-json" % AkkaHttpVersion libraryDependencies += "com.typesafe.akka" %% "akka-http-spray-json" % AkkaHttpVersion
libraryDependencies += "com.typesafe.akka" %% "akka-actor-typed" % AkkaVersion libraryDependencies += "com.typesafe.akka" %% "akka-actor-typed" % AkkaVersion
libraryDependencies += "com.typesafe.akka" %% "akka-stream" % AkkaVersion libraryDependencies += "com.typesafe.akka" %% "akka-stream" % AkkaVersion
libraryDependencies += "com.github.swagger-akka-http" %% "swagger-akka-http" % "2.6.0" libraryDependencies += "com.github.swagger-akka-http" %% "swagger-akka-http" % "2.11.0"
libraryDependencies += "jakarta.platform" % "jakarta.jakartaee-web-api" % "9.1.0" libraryDependencies += "jakarta.platform" % "jakarta.jakartaee-web-api" % "10.0.0"
libraryDependencies += "ch.megard" %% "akka-http-cors" % "1.2.0"
libraryDependencies += "io.spray" %% "spray-json" % "1.3.6" libraryDependencies += "io.spray" %% "spray-json" % "1.3.6"
libraryDependencies += "com.typesafe.slick" %% "slick" % SlickVersion libraryDependencies += "org.playframework.anorm" %% "anorm" % "2.7.0"
libraryDependencies += "com.typesafe.slick" %% "slick-hikaricp" % SlickVersion libraryDependencies += "com.zaxxer" % "HikariCP" % "5.1.0" exclude("org.slf4j", "slf4j-api")
libraryDependencies += "org.flywaydb" % "flyway-core" % "8.2.2" libraryDependencies += "org.flywaydb" % "flyway-core" % "9.16.0"
libraryDependencies += "org.xerial" % "sqlite-jdbc" % "3.36.0.3" libraryDependencies += "org.xerial" % "sqlite-jdbc" % "3.46.0.0"
libraryDependencies += "org.postgresql" % "postgresql" % "42.3.1" libraryDependencies += "org.postgresql" % "postgresql" % "42.7.3"
libraryDependencies += "org.mindrot" % "jbcrypt" % "0.4" libraryDependencies += "org.mindrot" % "jbcrypt" % "0.4"
libraryDependencies += "com.google.guava" % "guava" % "31.0.1-jre" libraryDependencies += "com.google.guava" % "guava" % "31.0.1-jre"
// testing // testing
libraryDependencies += "org.scalactic" %% "scalactic" % ScalaTestVersion % "test" libraryDependencies += "org.scalactic" %% "scalactic" % ScalaTestVersion % "test"
libraryDependencies += "org.scalatest" %% "scalatest" % ScalaTestVersion % "test" libraryDependencies += "org.scalatest" %% "scalatest" % ScalaTestVersion % "test"

View File

@ -1 +1 @@
sbt.version = 1.3.3 sbt.version = 1.7.1

View File

@ -1,4 +1,4 @@
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.6")
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.3") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.3")
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.9.3")
addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.4") addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.4")
addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.10.0-RC1") addDependencyTreePlugin

View File

@ -0,0 +1,2 @@
drop index bis_piece_type_player_id_idx;
create unique index bis_piece_type_player_id_idx on bis(player_id, piece, piece_type);

View File

@ -0,0 +1,3 @@
update parties set party_alias = regexp_replace(party_alias, '[^A-Za-z0-9!@#$%^&*()\-_=+;:'',./? ]', '', 'g');
update players set nick = regexp_replace(nick, '[^A-Za-z0-9!@#$%^&*()\-_=+;:'',./? ]', '', 'g');
update users set username = regexp_replace(username, '[^A-Za-z0-9!@#$%^&*()\-_=+;:'',./? ]', '', 'g');

View File

@ -0,0 +1 @@
update players set bis_link = null where bis_link = '';

View File

@ -0,0 +1,2 @@
drop index bis_piece_type_player_id_idx;
create unique index bis_piece_type_player_id_idx on bis(player_id, piece, piece_type);

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FFXIV loot helper API</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Embed elements Elements via Web Component -->
<script src="https://unpkg.com/@stoplight/elements/web-components.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@stoplight/elements/styles.min.css" type="text/css">
<link rel="shortcut icon" href="/static/favicon.ico">
</head>
<body>
<elements-api
apiDescriptionUrl="/api-docs/swagger.json"
router="hash"
layout="sidebar"
/>
</body>
</html>

View File

@ -6,17 +6,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/static/favicon.ico" rel="shortcut icon"> <link rel="shortcut icon" href="/static/favicon.ico">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.3/font/bootstrap-icons.css" type="text/css">
<link href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.20.2/dist/bootstrap-table.min.css" type="text/css">
<link href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" type="text/css">
<link href="/static/styles.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="/static/styles.css" type="text/css">
</head> </head>
<body> <body>
@ -68,6 +68,8 @@
data-show-search-clear-button="true" data-show-search-clear-button="true"
data-single-select="true" data-single-select="true"
data-sortable="true" data-sortable="true"
data-sort-name="nick"
data-sort-order="asc"
data-sort-reset="true" data-sort-reset="true"
data-toolbar="#toolbar"> data-toolbar="#toolbar">
<thead class="table-primary"> <thead class="table-primary">
@ -84,23 +86,24 @@
<div id="update-bis-dialog" tabindex="-1" role="dialog" class="modal fade"> <div id="update-bis-dialog" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<form class="modal-content"> <form class="modal-content" action="javascript:" onsubmit="updateBis()">
<div class="modal-header"> <div class="modal-header form-group row">
<div class="btn-group" role="group" aria-label="Update bis"> <div class="btn-group" role="group" aria-label="Update bis">
<input id="add-piece-btn" name="update-bis" type="radio" class="btn-check" autocomplete="off" checked> <input id="add-piece-btn" name="update-bis" type="radio" class="btn-check" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="add-piece-btn">add piece</label> <label class="btn btn-outline-primary" for="add-piece-btn">add piece</label>
<input id="update-bis-btn" name="update-bis" type="radio" class="btn-check" autocomplete="off"> <input id="update-bis-btn" name="update-bis" type="radio" class="btn-check" autocomplete="off">
<label class="btn btn-outline-primary" for="update-bis-btn">update bis</label> <label class="btn btn-outline-primary" for="update-bis-btn">update bis</label>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div> </div>
</div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="player">player</label> <label class="col-sm-4 col-form-label" for="player">player</label>
<div class="col-sm-8"> <div class="col-sm-8">
<select id="player" name="player" class="form-control" title="player"></select> <select id="player" name="player" class="form-control" title="player" required></select>
</div> </div>
</div> </div>
<div id="piece-row" class="form-group row"> <div id="piece-row" class="form-group row">
@ -118,14 +121,14 @@
<div id="bis-link-row" class="form-group row" style="display: none"> <div id="bis-link-row" class="form-group row" style="display: none">
<label class="col-sm-4 col-form-label" for="bis-link">link</label> <label class="col-sm-4 col-form-label" for="bis-link">link</label>
<div class="col-sm-8"> <div class="col-sm-8">
<input id="bis-link" name="link" class="form-control" placeholder="link to bis" onkeyup="disableSubmitBisButton()"> <input id="bis-link" name="link" class="form-control" placeholder="link to bis">
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button> <button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button id="submit-add-bis-btn" type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addPiece()" disabled>add</button> <button id="submit-add-bis-btn" type="submit" class="btn btn-primary">add</button>
<button id="submit-update-bis-btn" type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="setBis()" style="display: none" disabled>set</button> <button id="submit-set-bis-btn" type="submit" class="btn btn-primary" style="display: none">set</button>
</div> </div>
</div> </div>
</form> </form>
@ -136,10 +139,11 @@
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top"> <footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
<ul class="nav"> <ul class="nav">
<li><a class="nav-link" href="/" title="home">home</a></li> <li><a class="nav-link" href="/" title="home">home</a></li>
<li><a class="nav-link" href="/api-docs" title="api">api</a></li>
</ul> </ul>
<ul class="nav"> <ul class="nav">
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis" title="sources">ffxivbis</a></li> <li><a id="sources-link" class="nav-link" href="https://github.com/arcan1s/ffxivbis" title="sources">ffxivbis</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/releases" title="releases list">releases</a></li> <li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/releases" title="releases list">releases</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/issues" title="issues tracker">report a bug</a></li> <li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/issues" title="issues tracker">report a bug</a></li>
</ul> </ul>
@ -153,11 +157,11 @@
<script src="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js"></script> <script src="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.js"></script> <script src="https://unpkg.com/bootstrap-table@1.20.2/dist/bootstrap-table.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/export/bootstrap-table-export.min.js"></script> <script src="https://unpkg.com/bootstrap-table@1.20.2/dist/extensions/export/bootstrap-table-export.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/resizable/bootstrap-table-resizable.js"></script> <script src="https://unpkg.com/bootstrap-table@1.20.2/dist/extensions/resizable/bootstrap-table-resizable.js"></script>
<script src="/static/utils.js"></script> <script src="/static/utils.js"></script>
<script src="/static/load.js"></script> <script src="/static/load.js"></script>
@ -169,7 +173,7 @@
const updateButton = $("#update-btn"); const updateButton = $("#update-btn");
const submitAddBisButton = $("#submit-add-bis-btn"); const submitAddBisButton = $("#submit-add-bis-btn");
const submitUpdateBisButton = $("#submit-update-bis-btn"); const submitSetBisButton = $("#submit-set-bis-btn");
const updateBisDialog = $("#update-bis-dialog"); const updateBisDialog = $("#update-bis-dialog");
const addPieceButton = $("#add-piece-btn"); const addPieceButton = $("#add-piece-btn");
@ -203,15 +207,11 @@
}), }),
type: "POST", type: "POST",
contentType: "application/json", contentType: "application/json",
success: function (_) { reload(); }, success: _ => { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
}); });
} updateBisDialog.modal("hide");
return true; // action expects boolean result
function disableSubmitBisButton() {
const nonEmpty = (playerInput.val() !== null); // well lol
submitUpdateBisButton.attr("disabled", !(nonEmpty && linkInput.val()));
submitAddBisButton.attr("disabled", !(nonEmpty));
} }
function hideControls() { function hideControls() {
@ -220,20 +220,24 @@
} }
function hideLinkPart() { function hideLinkPart() {
disableSubmitBisButton();
bisLinkRow.hide(); bisLinkRow.hide();
submitUpdateBisButton.hide(); linkInput.prop("required", false);
submitSetBisButton.hide();
pieceRow.show(); pieceRow.show();
pieceTypeRow.show(); pieceTypeRow.show();
pieceInput.prop("required", true);
pieceTypeInput.prop("required", true);
submitAddBisButton.show(); submitAddBisButton.show();
} }
function hidePiecePart() { function hidePiecePart() {
disableSubmitBisButton();
bisLinkRow.show(); bisLinkRow.show();
submitUpdateBisButton.show(); linkInput.prop("required", true);
submitSetBisButton.show();
pieceRow.hide(); pieceRow.hide();
pieceTypeRow.hide(); pieceTypeRow.hide();
pieceInput.prop("required", false);
pieceTypeInput.prop("required", false);
submitAddBisButton.hide(); submitAddBisButton.hide();
} }
@ -243,9 +247,9 @@
url: `/api/v1/party/${partyId}`, url: `/api/v1/party/${partyId}`,
type: "GET", type: "GET",
dataType: "json", dataType: "json",
success: function (data) { success: response => {
const items = data.map(function (player) { const items = response.map(player => {
return player.bis.map(function (loot) { return player.bis.map(loot => {
return { return {
nick: player.nick, nick: player.nick,
job: player.job, job: player.job,
@ -254,12 +258,12 @@
}; };
}); });
}); });
const payload = items.reduce(function (left, right) { return left.concat(right); }, []); const payload = items.reduce((left, right) => { return left.concat(right); }, []);
table.bootstrapTable("load", payload); table.bootstrapTable("load", payload);
table.bootstrapTable("uncheckAll"); table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading"); table.bootstrapTable("hideLoading");
const options = data.map(function (player) { const options = response.map(player => {
const option = document.createElement("option"); const option = document.createElement("option");
option.innerText = formatPlayerId(player); option.innerText = formatPlayerId(player);
option.dataset.nick = player.nick; option.dataset.nick = player.nick;
@ -267,15 +271,14 @@
return option; return option;
}); });
playerInput.empty().append(options); playerInput.empty().append(options);
disableSubmitBisButton();
}, },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
}); });
} }
function removePiece() { function removePiece() {
const pieces = table.bootstrapTable("getSelections"); const pieces = table.bootstrapTable("getSelections");
pieces.map(function (loot) { pieces.map(loot => {
$.ajax({ $.ajax({
url: `/api/v1/party/${partyId}/bis`, url: `/api/v1/party/${partyId}/bis`,
data: JSON.stringify({ data: JSON.stringify({
@ -293,8 +296,8 @@
}), }),
type: "POST", type: "POST",
contentType: "application/json", contentType: "application/json",
success: function (_) { reload(); }, success: _ => { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
}); });
}); });
} }
@ -322,26 +325,40 @@
}), }),
type: "PUT", type: "PUT",
contentType: "application/json", contentType: "application/json",
success: function (_) { reload(); }, success: _ => { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
}); });
updateBisDialog.modal("hide");
return true; // action expects boolean result
} }
$(function () { function updateBis() {
if (updateBisButton.is(":checked")) {
return setBis();
}
if (addPieceButton.is(":checked")) {
return addPiece();
}
return false; // should not happen
}
$(() => {
setupFormClear(updateBisDialog, reset); setupFormClear(updateBisDialog, reset);
setupRemoveButton(table, removeButton); setupRemoveButton(table, removeButton);
loadHeader(partyId); loadHeader(partyId);
loadVersion();
loadTypes("/api/v1/types/pieces", pieceInput); loadTypes("/api/v1/types/pieces", pieceInput);
loadTypes("/api/v1/types/pieces/types", pieceTypeInput); loadTypes("/api/v1/types/pieces/types", pieceTypeInput);
hideControls(); hideControls();
updateBisButton.click(function () { reset(); }); updateBisButton.click(() => { reset(); });
addPieceButton.click(function () { reset(); }); addPieceButton.click(() => { reset(); });
table.bootstrapTable({}); table.bootstrapTable({});
reload(); reload();
reset();
}); });
</script> </script>

View File

@ -6,11 +6,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/static/favicon.ico" rel="shortcut icon"> <link rel="shortcut icon" href="/static/favicon.ico">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link href="/static/styles.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="/static/styles.css" type="text/css">
</head> </head>
<body> <body>
@ -71,10 +71,12 @@
<div class="container"> <div class="container">
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top"> <footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
<ul class="nav"></ul> <ul class="nav">
<li><a class="nav-link" href="/api-docs" title="api">api</a></li>
</ul>
<ul class="nav"> <ul class="nav">
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis" title="sources">ffxivbis</a></li> <li><a id="sources-link" class="nav-link" href="https://github.com/arcan1s/ffxivbis" title="sources">ffxivbis</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/releases" title="releases list">releases</a></li> <li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/releases" title="releases list">releases</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/issues" title="issues tracker">report a bug</a></li> <li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/issues" title="issues tracker">report a bug</a></li>
</ul> </ul>
@ -85,6 +87,9 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
<script src="/static/utils.js"></script>
<script src="/static/load.js"></script>
<script> <script>
const signinButton = $("#signin-btn"); const signinButton = $("#signin-btn");
const signupButton = $("#signup-btn"); const signupButton = $("#signup-btn");
@ -122,7 +127,7 @@
password: passwordInput.val(), password: passwordInput.val(),
permission: "admin", permission: "admin",
}), }),
type: "PUT", type: "POST",
contentType: "application/json", contentType: "application/json",
dataType: "json", dataType: "json",
success: function (data) { success: function (data) {
@ -174,6 +179,8 @@
} }
$(function () { $(function () {
loadVersion();
signinButton.click(function () { reset(); }); signinButton.click(function () { reset(); });
signupButton.click(function () { reset(); }); signupButton.click(function () { reset(); });
}); });

View File

@ -6,17 +6,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/static/favicon.ico" rel="shortcut icon"> <link rel="shortcut icon" href="/static/favicon.ico">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.3/font/bootstrap-icons.css" type="text/css">
<link href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.20.2/dist/bootstrap-table.min.css" type="text/css">
<link href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" type="text/css">
<link href="/static/styles.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="/static/styles.css" type="text/css">
</head> </head>
<body> <body>
@ -68,6 +68,8 @@
data-show-search-clear-button="true" data-show-search-clear-button="true"
data-single-select="true" data-single-select="true"
data-sortable="true" data-sortable="true"
data-sort-name="timestamp"
data-sort-order="desc"
data-sort-reset="true" data-sort-reset="true"
data-toolbar="#toolbar"> data-toolbar="#toolbar">
<thead class="table-primary"> <thead class="table-primary">
@ -86,41 +88,51 @@
<div id="add-loot-dialog" tabindex="-1" role="dialog" class="modal fade"> <div id="add-loot-dialog" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog modal-lg" role="document"> <div class="modal-dialog modal-lg" role="document">
<div class="modal-content"> <form class="modal-content" action="javascript:" onsubmit="addLootModal()">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">add looted piece</h4> <h4 class="modal-title">add looted piece</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div> </div>
<form class="modal-body"> <div class="modal-body">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="player">player</label> <label class="col-sm-4 col-form-label" for="player">player</label>
<div class="col-sm-8"> <div class="col-sm-8">
<select id="player" name="player" class="form-control" title="player"></select> <select id="player" name="player" class="form-control" title="player" required></select>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="piece">piece</label> <label class="col-sm-4 col-form-label" for="piece">piece</label>
<div class="col-sm-8"> <div class="col-sm-8">
<select id="piece" name="piece" class="form-control" title="piece"></select> <select id="piece" name="piece" class="form-control" title="piece" required></select>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="piece-type">piece type</label> <label class="col-sm-4 col-form-label" for="piece-type">piece type</label>
<div class="col-sm-8"> <div class="col-sm-8">
<select id="piece-type" name="pieceType" class="form-control" title="pieceType"></select> <select id="piece-type" name="pieceType" class="form-control" title="pieceType" required></select>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="job">job</label> <label class="col-sm-4 col-form-label" for="job">job</label>
<div class="col-sm-8"> <div class="col-sm-8">
<select id="job" name="job" class="form-control" title="job"></select> <select id="job" name="job" class="form-control" title="job" required></select>
</div>
</div>
<div class="form-group row">
<div class="col-sm-4"></div>
<div class="col-sm-8">
<div class="form-check">
<input id="free-loot" name="freeLoot" type="checkbox" class="form-check-input">
<label class="form-check-label" for="free-loot">as free loot</label>
</div>
</div> </div>
</div> </div>
<table id="stats" class="table table-striped table-hover"> <table id="stats" class="table table-striped table-hover">
<thead class="table-primary"> <thead class="table-primary">
<tr> <tr>
<th data-formatter="addLootFormatter"></th>
<th data-field="nick">nick</th> <th data-field="nick">nick</th>
<th data-field="job">job</th> <th data-field="job">job</th>
<th data-field="isRequired">required</th> <th data-field="isRequired">required</th>
@ -130,18 +142,14 @@
</tr> </tr>
</thead> </thead>
</table> </table>
</form> </div>
<div class="modal-footer"> <div class="modal-footer">
<div class="form-check form-switch">
<input id="free-loot" name="freeLoot" type="checkbox" class="form-check-input">
<label class="form-check-label" for="free-loot">as free loot</label>
</div>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button> <button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button type="button" class="btn btn-secondary" onclick="suggestLoot()">suggest</button> <button type="button" class="btn btn-secondary" onclick="suggestLoot()">suggest</button>
<button id="submit-btn" type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addLoot()" disabled>add</button> <button type="submit" class="btn btn-primary">add</button>
</div>
</div> </div>
</form>
</div> </div>
</div> </div>
@ -149,10 +157,11 @@
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top"> <footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
<ul class="nav"> <ul class="nav">
<li><a class="nav-link" href="/" title="home">home</a></li> <li><a class="nav-link" href="/" title="home">home</a></li>
<li><a class="nav-link" href="/api-docs" title="api">api</a></li>
</ul> </ul>
<ul class="nav"> <ul class="nav">
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis" title="sources">ffxivbis</a></li> <li><a id="sources-link" class="nav-link" href="https://github.com/arcan1s/ffxivbis" title="sources">ffxivbis</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/releases" title="releases list">releases</a></li> <li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/releases" title="releases list">releases</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/issues" title="issues tracker">report a bug</a></li> <li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/issues" title="issues tracker">report a bug</a></li>
</ul> </ul>
@ -166,11 +175,11 @@
<script src="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js"></script> <script src="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.js"></script> <script src="https://unpkg.com/bootstrap-table@1.20.2/dist/bootstrap-table.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/export/bootstrap-table-export.min.js"></script> <script src="https://unpkg.com/bootstrap-table@1.20.2/dist/extensions/export/bootstrap-table-export.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/resizable/bootstrap-table-resizable.js"></script> <script src="https://unpkg.com/bootstrap-table@1.20.2/dist/extensions/resizable/bootstrap-table-resizable.js"></script>
<script src="/static/utils.js"></script> <script src="/static/utils.js"></script>
<script src="/static/load.js"></script> <script src="/static/load.js"></script>
@ -182,7 +191,6 @@
const addButton = $("#add-btn"); const addButton = $("#add-btn");
const removeButton = $("#remove-btn"); const removeButton = $("#remove-btn");
const submitLootButton = $("#submit-btn");
const addLootDialog = $("#add-loot-dialog"); const addLootDialog = $("#add-loot-dialog");
const freeLootInput = $("#free-loot"); const freeLootInput = $("#free-loot");
@ -191,31 +199,43 @@
const pieceTypeInput = $("#piece-type"); const pieceTypeInput = $("#piece-type");
const playerInput = $("#player"); const playerInput = $("#player");
function addLoot() { function addLoot(nick, job) {
const player = getCurrentOption(playerInput);
$.ajax({ $.ajax({
url: `/api/v1/party/${partyId}/loot`, url: `/api/v1/party/${partyId}/loot`,
data: JSON.stringify({ data: JSON.stringify({
action: "add", action: "add",
piece: { piece: {
pieceType: pieceTypeInput.val(), pieceType: pieceTypeInput.val(),
job: player.dataset.job, job: job,
piece: pieceInput.val(), piece: pieceInput.val(),
}, },
playerId: { playerId: {
partyId: partyId, partyId: partyId,
nick: player.dataset.nick, nick: nick,
job: player.dataset.job, job: job,
}, },
isFreeLoot: freeLootInput.is(":checked"), isFreeLoot: freeLootInput.is(":checked"),
}), }),
type: "POST", type: "POST",
contentType: "application/json", contentType: "application/json",
success: function (_) { reload(); }, success: _ => {
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, addLootDialog.modal("hide");
reload();
},
error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
}); });
} }
function addLootFormatter(value, row, index) {
return `<button type="button" class="btn btn-primary" onclick="addLoot('${row.nick}', '${row.job}')"><i class="bi bi-plus"></i></button>`;
}
function addLootModal() {
const player = getCurrentOption(playerInput);
addLoot(player.dataset.nick, player.dataset.job);
return true; // action expects boolean result
}
function hideControls() { function hideControls() {
addButton.attr("hidden", isReadOnly); addButton.attr("hidden", isReadOnly);
removeButton.attr("hidden", isReadOnly); removeButton.attr("hidden", isReadOnly);
@ -227,9 +247,9 @@
url: `/api/v1/party/${partyId}`, url: `/api/v1/party/${partyId}`,
type: "GET", type: "GET",
dataType: "json", dataType: "json",
success: function (data) { success: response => {
const items = data.map(function (player) { const items = response.map(player => {
return player.loot.map(function (loot) { return player.loot.map(loot => {
return { return {
nick: player.nick, nick: player.nick,
job: player.job, job: player.job,
@ -240,12 +260,12 @@
}; };
}); });
}); });
const payload = items.reduce(function (left, right) { return left.concat(right); }, []); const payload = items.reduce((left, right) => { return left.concat(right); }, []);
table.bootstrapTable("load", payload); table.bootstrapTable("load", payload);
table.bootstrapTable("uncheckAll"); table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading"); table.bootstrapTable("hideLoading");
const options = data.map(function (player) { const options = response.map(player => {
const option = document.createElement("option"); const option = document.createElement("option");
option.innerText = formatPlayerId(player); option.innerText = formatPlayerId(player);
option.dataset.nick = player.nick; option.dataset.nick = player.nick;
@ -253,15 +273,14 @@
return option; return option;
}); });
playerInput.empty().append(options); playerInput.empty().append(options);
submitLootButton.attr("disabled", options.length === 0);
}, },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
}); });
} }
function removeLoot() { function removeLoot() {
const pieces = table.bootstrapTable("getSelections"); const pieces = table.bootstrapTable("getSelections");
pieces.map(function (loot) { pieces.map(loot => {
$.ajax({ $.ajax({
url: `/api/v1/party/${partyId}/loot`, url: `/api/v1/party/${partyId}/loot`,
data: JSON.stringify({ data: JSON.stringify({
@ -280,8 +299,8 @@
}), }),
type: "POST", type: "POST",
contentType: "application/json", contentType: "application/json",
success: function (_) { reload(); }, success: _ => { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
}); });
}); });
} }
@ -298,12 +317,12 @@
type: "PUT", type: "PUT",
contentType: "application/json", contentType: "application/json",
dataType: "json", dataType: "json",
success: function (data) { success: response => {
const payload = data.map(function (stat) { const payload = response.map(stat => {
return { return {
nick: stat.nick, nick: stat.nick,
job: stat.job, job: stat.job,
isRequired: stat.isRequired, isRequired: stat.isRequired ? "yes" : "no",
lootCount: stat.lootCount, lootCount: stat.lootCount,
lootCountBiS: stat.lootCountBiS, lootCountBiS: stat.lootCountBiS,
lootCountTotal: stat.lootCountTotal, lootCountTotal: stat.lootCountTotal,
@ -313,14 +332,15 @@
stats.bootstrapTable("uncheckAll"); stats.bootstrapTable("uncheckAll");
stats.bootstrapTable("hideLoading"); stats.bootstrapTable("hideLoading");
}, },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
}); });
} }
$(function () { $(() => {
setupFormClear(addLootDialog); setupFormClear(addLootDialog);
setupRemoveButton(table, removeButton); setupRemoveButton(table, removeButton);
loadVersion();
loadHeader(partyId); loadHeader(partyId);
loadTypes("/api/v1/types/jobs/all", jobInput); loadTypes("/api/v1/types/jobs/all", jobInput);
loadTypes("/api/v1/types/pieces", pieceInput); loadTypes("/api/v1/types/pieces", pieceInput);

View File

@ -6,17 +6,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/static/favicon.ico" rel="shortcut icon"> <link rel="shortcut icon" href="/static/favicon.ico">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.3/font/bootstrap-icons.css" type="text/css">
<link href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.20.2/dist/bootstrap-table.min.css" type="text/css">
<link href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" type="text/css">
<link href="/static/styles.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="/static/styles.css" type="text/css">
</head> </head>
<body> <body>
@ -64,6 +64,8 @@
data-show-search-clear-button="true" data-show-search-clear-button="true"
data-single-select="true" data-single-select="true"
data-sortable="true" data-sortable="true"
data-sort-name="nick"
data-sort-order="asc"
data-sort-reset="true" data-sort-reset="true"
data-toolbar="#toolbar"> data-toolbar="#toolbar">
<thead class="table-primary"> <thead class="table-primary">
@ -82,23 +84,23 @@
<div id="add-player-dialog" tabindex="-1" role="dialog" class="modal fade"> <div id="add-player-dialog" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <form class="modal-content" action="javascript:" onsubmit="addPlayer()">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">add new player</h4> <h4 class="modal-title">add new player</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div> </div>
<form class="modal-body"> <div class="modal-body">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="nick">player name</label> <label class="col-sm-4 col-form-label" for="nick">player name</label>
<div class="col-sm-8"> <div class="col-sm-8">
<input id="nick" name="nick" class="form-control" placeholder="nick" onkeyup="disableAddPlayerForm()"> <input id="nick" name="nick" class="form-control" placeholder="nick" required>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="job">player job</label> <label class="col-sm-4 col-form-label" for="job">player job</label>
<div class="col-sm-8"> <div class="col-sm-8">
<select id="job" name="job" class="form-control" title="job"></select> <select id="job" name="job" class="form-control" title="job" required></select>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
@ -113,13 +115,13 @@
<input id="priority" name="priority" type="number" class="form-control" value="0"> <input id="priority" name="priority" type="number" class="form-control" value="0">
</div> </div>
</div> </div>
</form> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button> <button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button id="submit-player-btn" type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addPlayer()" disabled>add</button> <button type="submit" class="btn btn-primary">add</button>
</div>
</div> </div>
</form>
</div> </div>
</div> </div>
@ -127,10 +129,11 @@
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top"> <footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
<ul class="nav"> <ul class="nav">
<li><a class="nav-link" href="/" title="home">home</a></li> <li><a class="nav-link" href="/" title="home">home</a></li>
<li><a class="nav-link" href="/api-docs" title="api">api</a></li>
</ul> </ul>
<ul class="nav"> <ul class="nav">
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis" title="sources">ffxivbis</a></li> <li><a id="sources-link" class="nav-link" href="https://github.com/arcan1s/ffxivbis" title="sources">ffxivbis</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/releases" title="releases list">releases</a></li> <li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/releases" title="releases list">releases</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/issues" title="issues tracker">report a bug</a></li> <li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/issues" title="issues tracker">report a bug</a></li>
</ul> </ul>
@ -144,11 +147,11 @@
<script src="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js"></script> <script src="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.js"></script> <script src="https://unpkg.com/bootstrap-table@1.20.2/dist/bootstrap-table.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/export/bootstrap-table-export.min.js"></script> <script src="https://unpkg.com/bootstrap-table@1.20.2/dist/extensions/export/bootstrap-table-export.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/resizable/bootstrap-table-resizable.js"></script> <script src="https://unpkg.com/bootstrap-table@1.20.2/dist/extensions/resizable/bootstrap-table-resizable.js"></script>
<script src="/static/utils.js"></script> <script src="/static/utils.js"></script>
<script src="/static/load.js"></script> <script src="/static/load.js"></script>
@ -160,7 +163,6 @@
const removeButton = $("#remove-btn"); const removeButton = $("#remove-btn");
const addPlayerDialog = $("#add-player-dialog"); const addPlayerDialog = $("#add-player-dialog");
const submitPlayerButton = $("#submit-player-btn");
const jobInput = $("#job"); const jobInput = $("#job");
const linkInput = $("#link"); const linkInput = $("#link");
@ -182,9 +184,11 @@
}), }),
type: "POST", type: "POST",
contentType: "application/json", contentType: "application/json",
success: function (_) { reload(); }, success: _ => { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
}); });
addPlayerDialog.modal("hide");
return true; // action expects boolean result
} }
function bisLinkFormatter(link, row) { function bisLinkFormatter(link, row) {
@ -195,10 +199,6 @@
} }
} }
function disableAddPlayerForm() {
submitPlayerButton.attr("disabled", !nickInput.val());
}
function hideControls() { function hideControls() {
addButton.attr("hidden", isReadOnly); addButton.attr("hidden", isReadOnly);
removeButton.attr("hidden", isReadOnly); removeButton.attr("hidden", isReadOnly);
@ -210,18 +210,18 @@
url: `/api/v1/party/${partyId}`, url: `/api/v1/party/${partyId}`,
type: "GET", type: "GET",
dataType: "json", dataType: "json",
success: function (data) { success: response => {
table.bootstrapTable("load", data); table.bootstrapTable("load", response);
table.bootstrapTable("uncheckAll"); table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading"); table.bootstrapTable("hideLoading");
}, },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
}); });
} }
function removePlayers() { function removePlayers() {
const players = table.bootstrapTable("getSelections"); const players = table.bootstrapTable("getSelections");
players.map(function (player) { players.map(player => {
$.ajax({ $.ajax({
url: `/api/v1/party/${partyId}`, url: `/api/v1/party/${partyId}`,
data: JSON.stringify({ data: JSON.stringify({
@ -234,16 +234,17 @@
}), }),
type: "POST", type: "POST",
contentType: "application/json", contentType: "application/json",
success: function (_) { reload(); }, success: _ => { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
}); });
}); });
} }
$(function () { $(() => {
setupFormClear(addPlayerDialog); setupFormClear(addPlayerDialog);
setupRemoveButton(table, removeButton); setupRemoveButton(table, removeButton);
loadVersion();
loadHeader(partyId); loadHeader(partyId);
loadTypes("/api/v1/types/jobs", jobInput); loadTypes("/api/v1/types/jobs", jobInput);

View File

@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FFXIV loot helper API</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet" type="text/css">
<link href="/static/favicon.ico" rel="shortcut icon">
</head>
<body>
<redoc spec-url="/api-docs/swagger.json"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
</body>
</html>

View File

@ -6,17 +6,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/static/favicon.ico" rel="shortcut icon"> <link rel="shortcut icon" href="/static/favicon.ico">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.3/font/bootstrap-icons.css" type="text/css">
<link href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.20.2/dist/bootstrap-table.min.css" type="text/css">
<link href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" type="text/css">
<link href="/static/styles.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="/static/styles.css" type="text/css">
</head> </head>
<body> <body>
@ -68,6 +68,8 @@
data-show-search-clear-button="true" data-show-search-clear-button="true"
data-single-select="true" data-single-select="true"
data-sortable="true" data-sortable="true"
data-sort-name="username"
data-sort-order="asc"
data-sort-reset="true" data-sort-reset="true"
data-toolbar="#toolbar"> data-toolbar="#toolbar">
<thead class="table-primary"> <thead class="table-primary">
@ -82,38 +84,38 @@
<div id="add-user-dialog" tabindex="-1" role="dialog" class="modal fade"> <div id="add-user-dialog" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <form class="modal-content" action="javascript:" onsubmit="addUser()">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">add new user</h4> <h4 class="modal-title">add new user</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div> </div>
<form class="modal-body"> <div class="modal-body">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="username">login</label> <label class="col-sm-4 col-form-label" for="username">login</label>
<div class="col-sm-8"> <div class="col-sm-8">
<input id="username" name="username" class="form-control" placeholder="username" onkeyup="disableAddUserForm()"> <input id="username" name="username" class="form-control" placeholder="username" required>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="password">password</label> <label class="col-sm-4 col-form-label" for="password">password</label>
<div class="col-sm-8"> <div class="col-sm-8">
<input id="password" name="password" type="password" class="form-control" placeholder="password" onkeyup="disableAddUserForm()"> <input id="password" name="password" type="password" class="form-control" placeholder="password" required>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="permission">permission</label> <label class="col-sm-4 col-form-label" for="permission">permission</label>
<div class="col-sm-8"> <div class="col-sm-8">
<select id="permission" name="permission" class="form-control" title="permission"></select> <select id="permission" name="permission" class="form-control" title="permission" required></select>
</div>
</div> </div>
</div> </div>
</form>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button> <button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button id="submit-btn" type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addUser()" disabled>add</button> <button type="submit" class="btn btn-primary">add</button>
</div>
</div> </div>
</form>
</div> </div>
</div> </div>
@ -121,10 +123,11 @@
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top"> <footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
<ul class="nav"> <ul class="nav">
<li><a class="nav-link" href="/" title="home">home</a></li> <li><a class="nav-link" href="/" title="home">home</a></li>
<li><a class="nav-link" href="/api-docs" title="api">api</a></li>
</ul> </ul>
<ul class="nav"> <ul class="nav">
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis" title="sources">ffxivbis</a></li> <li><a id="sources-link" class="nav-link" href="https://github.com/arcan1s/ffxivbis" title="sources">ffxivbis</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/releases" title="releases list">releases</a></li> <li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/releases" title="releases list">releases</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/issues" title="issues tracker">report a bug</a></li> <li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/issues" title="issues tracker">report a bug</a></li>
</ul> </ul>
@ -138,11 +141,11 @@
<script src="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js"></script> <script src="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.js"></script> <script src="https://unpkg.com/bootstrap-table@1.20.2/dist/bootstrap-table.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/export/bootstrap-table-export.min.js"></script> <script src="https://unpkg.com/bootstrap-table@1.20.2/dist/extensions/export/bootstrap-table-export.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/resizable/bootstrap-table-resizable.js"></script> <script src="https://unpkg.com/bootstrap-table@1.20.2/dist/extensions/resizable/bootstrap-table-resizable.js"></script>
<script src="/static/utils.js"></script> <script src="/static/utils.js"></script>
<script src="/static/load.js"></script> <script src="/static/load.js"></script>
@ -154,7 +157,6 @@
const removeButton = $("#remove-btn"); const removeButton = $("#remove-btn");
const addUserDialog = $("#add-user-dialog"); const addUserDialog = $("#add-user-dialog");
const submitUserButton = $("#submit-btn");
const usernameInput = $("#username"); const usernameInput = $("#username");
const passwordInput = $("#password"); const passwordInput = $("#password");
@ -171,13 +173,11 @@
}), }),
type: "POST", type: "POST",
contentType: "application/json", contentType: "application/json",
success: function (_) { reload(); }, success: _ => { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
}); });
} addUserDialog.modal("hide");
return true; // action expects boolean result
function disableAddUserForm() {
submitUserButton.attr("disabled", !(usernameInput.val() && passwordInput.val()));
} }
function hideControls() { function hideControls() {
@ -191,31 +191,32 @@
url: `/api/v1/party/${partyId}/users`, url: `/api/v1/party/${partyId}/users`,
type: "GET", type: "GET",
dataType: "json", dataType: "json",
success: function (data) { success: response => {
table.bootstrapTable("load", data); table.bootstrapTable("load", response);
table.bootstrapTable("uncheckAll"); table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading"); table.bootstrapTable("hideLoading");
}, },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
}); });
} }
function removeUsers() { function removeUsers() {
const users = table.bootstrapTable("getSelections"); const users = table.bootstrapTable("getSelections");
users.map(function (user) { users.map(user => {
$.ajax({ $.ajax({
url: `/api/v1/party/${partyId}/users/${user.username}`, url: `/api/v1/party/${partyId}/users/${user.username}`,
type: "DELETE", type: "DELETE",
success: function (_) { reload(); }, success: _ => { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
}); });
}); });
} }
$(function () { $(() => {
setupFormClear(addUserDialog); setupFormClear(addUserDialog);
setupRemoveButton(table, removeButton); setupRemoveButton(table, removeButton);
loadVersion();
loadHeader(partyId); loadHeader(partyId);
loadTypes("/api/v1/types/permissions", permissionInput); loadTypes("/api/v1/types/permissions", permissionInput);

View File

@ -3,15 +3,15 @@
<include resource="logback-application.xml" /> <include resource="logback-application.xml" />
<include resource="logback-http.xml" /> <include resource="logback-http.xml" />
<root level="debug"> <root level="DEBUG">
<appender-ref ref="application" /> <appender-ref ref="application" />
</root> </root>
<logger name="me.arcanis.ffxivbis" level="DEBUG" />
<logger name="http" level="DEBUG" additivity="false"> <logger name="http" level="DEBUG" additivity="false">
<appender-ref ref="http" /> <appender-ref ref="http" />
</logger> </logger>
<logger name="slick" level="INFO" />
<logger name="org.flywaydb.core.internal" level="INFO" /> <logger name="org.flywaydb.core.internal" level="INFO" />
<logger name="com.zaxxer.hikari.pool" level="INFO" />
<logger name="io.swagger" level="INFO" />
</configuration> </configuration>

View File

@ -15,26 +15,17 @@ me.arcanis.ffxivbis {
mode = "sqlite" mode = "sqlite"
sqlite { sqlite {
profile = "slick.jdbc.SQLiteProfile$" driverClassName = "org.sqlite.JDBC"
db { jdbcUrl = "jdbc:sqlite:ffxivbis.db"
url = "jdbc:sqlite:ffxivbis.db" #username = "user"
#user = "user"
#password = "password" #password = "password"
} }
numThreads = 10
}
postgresql { postgresql {
profile = "slick.jdbc.PostgresProfile$" driverClassName = "org.postgresql.Driver"
db { jdbcUrl = "jdbc:postgresql://localhost/ffxivbis"
url = "jdbc:postgresql://localhost/ffxivbis" #username = "ffxivbis"
#user = "ffxivbis"
#password = "ffxivbis" #password = "ffxivbis"
connectionPool = disabled
keepAliveConnection = yes
}
numThreads = 10
} }
} }
@ -59,6 +50,7 @@ me.arcanis.ffxivbis {
#hostname = "127.0.0.1:8000" #hostname = "127.0.0.1:8000"
# enable head requests for GET requests # enable head requests for GET requests
enable-head-requests = yes enable-head-requests = yes
schemes = ["http"]
authorization-cache { authorization-cache {
# maximum amount of cached logins # maximum amount of cached logins

View File

@ -36,8 +36,18 @@ function loadTypes(url, selector) {
}); });
} }
function loadVersion() {
$.ajax({
url: "/api/v1/status",
type: "GET",
dataType: "json",
success: function (data) { $("#sources-link").text(`ffxivbis ${data.version}`); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
function setupFormClear(dialog, reset) { function setupFormClear(dialog, reset) {
dialog.on("shown.bs.modal", function () { dialog.on("hide.bs.modal", function () {
$(this).find("form").trigger("reset"); $(this).find("form").trigger("reset");
$(this).find("table").bootstrapTable("removeAll"); $(this).find("table").bootstrapTable("removeAll");
if (reset) { if (reset) {

View File

@ -1,4 +1,4 @@
REST json API description to interact with FFXIVBiS service. REST json API description to interact with FFXIV Best-in-slot service.
# Basic workflow # Basic workflow

View File

@ -17,8 +17,7 @@ import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.http.RootEndpoint import me.arcanis.ffxivbis.http.RootEndpoint
import me.arcanis.ffxivbis.service.PartyService import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.bis.BisProvider import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.database.Database import me.arcanis.ffxivbis.service.database.{Database, Migration}
import me.arcanis.ffxivbis.storage.Migration
import scala.concurrent.ExecutionContext import scala.concurrent.ExecutionContext
import scala.jdk.CollectionConverters._ import scala.jdk.CollectionConverters._
@ -51,7 +50,7 @@ class Application(context: ActorContext[Nothing]) extends AbstractBehavior[Nothi
val party = context.spawn(PartyService(storage), "party") val party = context.spawn(PartyService(storage), "party")
val http = new RootEndpoint(context.system, party, bisProvider) val http = new RootEndpoint(context.system, party, bisProvider)
val flow = Route.toFlow(http.route)(context.system) val flow = Route.toFlow(http.routes)(context.system)
Http(context.system).newServerAt(host, port).bindFlow(flow) Http(context.system).newServerAt(host, port).bindFlow(flow)
case Success(result) => case Success(result) =>

View File

@ -10,8 +10,7 @@ package me.arcanis.ffxivbis
import akka.actor.typed.ActorSystem import akka.actor.typed.ActorSystem
object ffxivbis { object ffxivbis extends App {
def main(args: Array[String]): Unit =
ActorSystem[Nothing](Application(), "ffxivbis", Configuration.load()) ActorSystem[Nothing](Application(), "ffxivbis", Configuration.load())
} }

View File

@ -37,23 +37,16 @@ trait Authorization {
def authAdmin(partyId: String)(username: String, password: String)(implicit def authAdmin(partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext executionContext: ExecutionContext
): Future[Option[User]] = ): Future[Option[User]] =
authenticator(Permission.admin, partyId)(username, password) auth.authenticator(Permission.admin, partyId)(username, password)
def authGet(partyId: String)(username: String, password: String)(implicit def authGet(partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext executionContext: ExecutionContext
): Future[Option[User]] = ): Future[Option[User]] =
authenticator(Permission.get, partyId)(username, password) auth.authenticator(Permission.get, partyId)(username, password)
def authPost(partyId: String)(username: String, password: String)(implicit def authPost(partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext executionContext: ExecutionContext
): Future[Option[User]] = ): Future[Option[User]] =
authenticator(Permission.post, partyId)(username, password) auth.authenticator(Permission.post, partyId)(username, password)
private def authenticator(scope: Permission.Value, partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext
): Future[Option[User]] =
auth.get(partyId, username).map {
case Some(user) if user.verify(password) && user.verityScope(scope) => Some(user)
case _ => None
}
} }

View File

@ -13,20 +13,33 @@ import akka.actor.typed.{ActorRef, Scheduler}
import akka.util.Timeout import akka.util.Timeout
import com.google.common.cache.{CacheBuilder, CacheLoader, LoadingCache} import com.google.common.cache.{CacheBuilder, CacheLoader, LoadingCache}
import com.typesafe.config.Config import com.typesafe.config.Config
import me.arcanis.ffxivbis.messages.{GetUser, Message} import me.arcanis.ffxivbis.messages.DatabaseMessage.GetUser
import me.arcanis.ffxivbis.models.User import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.{Permission, User}
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import scala.concurrent.Future import scala.concurrent.{ExecutionContext, Future}
trait AuthorizationProvider { trait AuthorizationProvider {
def get(partyId: String, username: String): Future[Option[User]] def get(partyId: String, username: String): Future[Option[User]]
def authenticator[T](scope: Permission.Value, partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext,
extractor: User => T
): Future[Option[T]] =
get(partyId, username).map {
case Some(user) if user.verify(password) && user.verityScope(scope) => Some(extractor(user))
case _ => None
}
} }
object AuthorizationProvider { object AuthorizationProvider {
def apply(config: Config, storage: ActorRef[Message], timeout: Timeout, scheduler: Scheduler): AuthorizationProvider = def apply(config: Config, storage: ActorRef[Message])(implicit
timeout: Timeout,
scheduler: Scheduler
): AuthorizationProvider =
new AuthorizationProvider { new AuthorizationProvider {
private val cacheSize = config.getInt("me.arcanis.ffxivbis.web.authorization-cache.cache-size") private val cacheSize = config.getInt("me.arcanis.ffxivbis.web.authorization-cache.cache-size")
private val cacheTimeout = private val cacheTimeout =

View File

@ -13,8 +13,8 @@ import akka.http.scaladsl.server.Directive0
import akka.http.scaladsl.server.Directives.{extractClientIP, extractRequestContext, mapResponse, optionalHeaderValueByType} import akka.http.scaladsl.server.Directives.{extractClientIP, extractRequestContext, mapResponse, optionalHeaderValueByType}
import com.typesafe.scalalogging.Logger import com.typesafe.scalalogging.Logger
import java.time.{Instant, ZoneId}
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.{Instant, ZoneId}
import java.util.Locale import java.util.Locale
trait HttpLog { trait HttpLog {
@ -68,7 +68,7 @@ object HttpLog {
val httpLogDatetimeFormatter: DateTimeFormatter = val httpLogDatetimeFormatter: DateTimeFormatter =
DateTimeFormatter DateTimeFormatter
.ofPattern("dd/MMM/uuuu:HH:mm:ss xx ") .ofPattern("dd/MMM/uuuu:HH:mm:ss xx")
.withLocale(Locale.UK) .withLocale(Locale.UK)
.withZone(ZoneId.systemDefault()) .withZone(ZoneId.systemDefault())
} }

View File

@ -12,6 +12,7 @@ import akka.actor.typed.{ActorRef, ActorSystem, Scheduler}
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._ import akka.http.scaladsl.server._
import akka.util.Timeout import akka.util.Timeout
import ch.megard.akka.http.cors.scaladsl.CorsDirectives.cors
import com.typesafe.scalalogging.StrictLogging import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.http.api.v1.RootApiV1Endpoint import me.arcanis.ffxivbis.http.api.v1.RootApiV1Endpoint
import me.arcanis.ffxivbis.http.view.RootView import me.arcanis.ffxivbis.http.view.RootView
@ -25,36 +26,38 @@ class RootEndpoint(system: ActorSystem[Nothing], storage: ActorRef[Message], pro
private val config = system.settings.config private val config = system.settings.config
implicit val scheduler: Scheduler = system.scheduler implicit val scheduler: Scheduler = system.scheduler
implicit val timeout: Timeout = config.getDuration("me.arcanis.ffxivbis.settings.request-timeout") implicit val timeout: Timeout = config.getTimeout("me.arcanis.ffxivbis.settings.request-timeout")
private val auth = AuthorizationProvider(config, storage, timeout, scheduler) private val auth = AuthorizationProvider(config, storage)
private val rootApiV1Endpoint = new RootApiV1Endpoint(storage, auth, provider, config) private val rootApiV1Endpoint = new RootApiV1Endpoint(storage, auth, provider, config)
private val rootView = new RootView(auth) private val rootView = new RootView(auth)
private val swagger = new Swagger(config) private val swagger = new Swagger(config)
def route: Route = def routes: Route =
withHttpLog { withHttpLog {
ignoreTrailingSlash { ignoreTrailingSlash {
apiRoute ~ htmlRoute ~ swagger.routes ~ swaggerUIRoute cors() {
apiRoutes ~ htmlRoutes ~ swagger.routes ~ swaggerUIRoutes
}
} }
} }
private def apiRoute: Route = private def apiRoutes: Route =
pathPrefix("api") { pathPrefix("api") {
pathPrefix(Segment) { pathPrefix(Segment) {
case "v1" => rootApiV1Endpoint.route case "v1" => rootApiV1Endpoint.routes
case _ => reject case _ => reject
} }
} }
private def htmlRoute: Route = private def htmlRoutes: Route =
pathPrefix("static") { pathPrefix("static") {
getFromResourceDirectory("static") getFromResourceDirectory("static")
} ~ rootView.route } ~ rootView.routes
private def swaggerUIRoute: Route = private def swaggerUIRoutes: Route =
path("api-docs") { path("api-docs") {
getFromResource("html/redoc.html") getFromResource("html/api.html")
} }
} }

View File

@ -14,6 +14,7 @@ import com.typesafe.config.Config
import io.swagger.v3.oas.models.security.SecurityScheme import io.swagger.v3.oas.models.security.SecurityScheme
import scala.io.Source import scala.io.Source
import scala.jdk.CollectionConverters._
class Swagger(config: Config) extends SwaggerHttpService { class Swagger(config: Config) extends SwaggerHttpService {
@ -22,6 +23,7 @@ class Swagger(config: Config) extends SwaggerHttpService {
classOf[api.v1.LootEndpoint], classOf[api.v1.LootEndpoint],
classOf[api.v1.PartyEndpoint], classOf[api.v1.PartyEndpoint],
classOf[api.v1.PlayerEndpoint], classOf[api.v1.PlayerEndpoint],
classOf[api.v1.StatusEndpoint],
classOf[api.v1.TypesEndpoint], classOf[api.v1.TypesEndpoint],
classOf[api.v1.UserEndpoint] classOf[api.v1.UserEndpoint]
) )
@ -35,15 +37,17 @@ class Swagger(config: Config) extends SwaggerHttpService {
override val host: String = override val host: String =
if (config.hasPath("me.arcanis.ffxivbis.web.hostname")) config.getString("me.arcanis.ffxivbis.web.hostname") if (config.hasPath("me.arcanis.ffxivbis.web.hostname")) config.getString("me.arcanis.ffxivbis.web.hostname")
else s"${config.getString("me.arcanis.ffxivbis.web.host")}:${config.getString("me.arcanis.ffxivbis.web.port")}" else s"${config.getString("me.arcanis.ffxivbis.web.host")}:${config.getInt("me.arcanis.ffxivbis.web.port")}"
override val schemes: List[String] = config.getStringList("me.arcanis.ffxivbis.web.schemes").asScala.toList
private val basicAuth = new SecurityScheme() private val basicAuth = new SecurityScheme()
.description("basic http auth") .description("basic http auth")
.`type`(SecurityScheme.Type.HTTP) .`type`(SecurityScheme.Type.HTTP)
.in(SecurityScheme.In.HEADER) .in(SecurityScheme.In.HEADER)
.scheme("bearer") .scheme("basic")
override val securitySchemes: Map[String, SecurityScheme] = Map("basic auth" -> basicAuth) override val securitySchemes: Map[String, SecurityScheme] = Map("basic" -> basicAuth)
override val unwantedDefinitions: Seq[String] = override val unwantedDefinitions: Seq[String] =
Seq("Function1", "Function1RequestContextFutureRouteResult") Seq("Function1", "Function1RequestContextFutureRouteResult", "SeqLootModel", "SeqPieceModel")
} }

View File

@ -0,0 +1,16 @@
package me.arcanis.ffxivbis.http
import scala.collection.immutable.HashSet
trait ValidatorHelper {
def isValidString(string: String): Boolean = string.nonEmpty && string.forall(isValidSymbol)
def isValidSymbol(char: Char): Boolean =
char.isLetterOrDigit || ValidatorHelper.VALID_CHARACTERS.contains(char)
}
object ValidatorHelper {
final val VALID_CHARACTERS = HashSet.from("!@#$%^&*()-_=+;:',./? ")
}

View File

@ -40,7 +40,7 @@ class BiSEndpoint(
with Authorization with Authorization
with JsonSupport { with JsonSupport {
def route: Route = createBiS ~ getBiS ~ modifyBiS def routes: Route = createBiS ~ getBiS ~ modifyBiS
@PUT @PUT
@Path("party/{partyId}/bis") @Path("party/{partyId}/bis")
@ -49,7 +49,12 @@ class BiSEndpoint(
summary = "create best in slot", summary = "create best in slot",
description = "Create the best in slot set", description = "Create the best in slot set",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
), ),
requestBody = new RequestBody( requestBody = new RequestBody(
description = "player best in slot description", description = "player best in slot description",
@ -79,7 +84,7 @@ class BiSEndpoint(
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
), ),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))), security = Array(new SecurityRequirement(name = "basic", scopes = Array("post"))),
tags = Array("best in slot"), tags = Array("best in slot"),
) )
def createBiS: Route = def createBiS: Route =
@ -105,7 +110,12 @@ class BiSEndpoint(
summary = "get best in slot", summary = "get best in slot",
description = "Return the best in slot items", description = "Return the best in slot items",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
new Parameter( new Parameter(
name = "nick", name = "nick",
in = ParameterIn.QUERY, in = ParameterIn.QUERY,
@ -140,7 +150,7 @@ class BiSEndpoint(
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
), ),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))), security = Array(new SecurityRequirement(name = "basic", scopes = Array("get"))),
tags = Array("best in slot"), tags = Array("best in slot"),
) )
def getBiS: Route = def getBiS: Route =
@ -167,7 +177,12 @@ class BiSEndpoint(
summary = "modify best in slot", summary = "modify best in slot",
description = "Add or remove an item from the best in slot", description = "Add or remove an item from the best in slot",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
), ),
requestBody = new RequestBody( requestBody = new RequestBody(
description = "action and piece description", description = "action and piece description",
@ -197,7 +212,7 @@ class BiSEndpoint(
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
), ),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))), security = Array(new SecurityRequirement(name = "basic", scopes = Array("post"))),
tags = Array("best in slot"), tags = Array("best in slot"),
) )
def modifyBiS: Route = def modifyBiS: Route =

View File

@ -11,25 +11,45 @@ package me.arcanis.ffxivbis.http.api.v1
import akka.http.scaladsl.model._ import akka.http.scaladsl.model._
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._ import akka.http.scaladsl.server._
import ch.megard.akka.http.cors.scaladsl.CorsDirectives.corsRejectionHandler
import com.typesafe.scalalogging.StrictLogging import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.http.api.v1.json._ import me.arcanis.ffxivbis.http.api.v1.json._
import spray.json._ import spray.json._
import scala.util.control.NonFatal
trait HttpHandler extends StrictLogging { this: JsonSupport => trait HttpHandler extends StrictLogging { this: JsonSupport =>
def exceptionHandler: ExceptionHandler = ExceptionHandler { def exceptionHandler: ExceptionHandler = ExceptionHandler {
case ex: IllegalArgumentException => case exception: IllegalArgumentException =>
complete(StatusCodes.BadRequest, ErrorModel(ex.getMessage)) complete(StatusCodes.BadRequest, ErrorModel(exception.getMessage))
case other: Exception => case NonFatal(other) =>
logger.error("exception during request completion", other) logger.error("exception during request completion", other)
complete(StatusCodes.InternalServerError, ErrorModel("unknown server error")) complete(StatusCodes.InternalServerError, ErrorModel("unknown server error"))
} }
def rejectionHandler: RejectionHandler = def rejectionHandler: RejectionHandler =
RejectionHandler.default RejectionHandler
.newBuilder()
.handleAll[MethodRejection] { rejections =>
val (methods, names) = rejections.map(r => r.supported -> r.supported.name).unzip
respondWithHeader(headers.Allow(methods)) {
options {
complete(StatusCodes.OK, HttpEntity.Empty)
} ~
complete(
StatusCodes.MethodNotAllowed,
s"HTTP method not allowed, supported methods: ${names.mkString(", ")}"
)
}
}
.result()
.withFallback(corsRejectionHandler)
.seal
.mapRejectionResponse { .mapRejectionResponse {
case response @ HttpResponse(_, _, entity: HttpEntity.Strict, _) => case response @ HttpResponse(_, _, entity: HttpEntity.Strict, _) if entity.data.nonEmpty =>
val message = ErrorModel(entity.data.utf8String).toJson val message = ErrorModel(entity.data.utf8String).toJson
response.withEntity(HttpEntity(ContentTypes.`application/json`, message.compactPrint)) response.withEntity(HttpEntity(ContentTypes.`application/json`, message.compactPrint))
case other => other case other => other

View File

@ -37,7 +37,7 @@ class LootEndpoint(override val storage: ActorRef[Message], override val auth: A
with JsonSupport with JsonSupport
with HttpHandler { with HttpHandler {
def route: Route = getLoot ~ modifyLoot ~ suggestLoot def routes: Route = getLoot ~ modifyLoot ~ suggestLoot
@GET @GET
@Path("party/{partyId}/loot") @Path("party/{partyId}/loot")
@ -46,7 +46,12 @@ class LootEndpoint(override val storage: ActorRef[Message], override val auth: A
summary = "get loot list", summary = "get loot list",
description = "Return the looted items", description = "Return the looted items",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
new Parameter( new Parameter(
name = "nick", name = "nick",
in = ParameterIn.QUERY, in = ParameterIn.QUERY,
@ -81,7 +86,7 @@ class LootEndpoint(override val storage: ActorRef[Message], override val auth: A
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
), ),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))), security = Array(new SecurityRequirement(name = "basic", scopes = Array("get"))),
tags = Array("loot"), tags = Array("loot"),
) )
def getLoot: Route = def getLoot: Route =
@ -107,7 +112,12 @@ class LootEndpoint(override val storage: ActorRef[Message], override val auth: A
summary = "modify loot list", summary = "modify loot list",
description = "Add or remove an item from the loot list", description = "Add or remove an item from the loot list",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
), ),
requestBody = new RequestBody( requestBody = new RequestBody(
description = "action and piece description", description = "action and piece description",
@ -137,7 +147,7 @@ class LootEndpoint(override val storage: ActorRef[Message], override val auth: A
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
), ),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))), security = Array(new SecurityRequirement(name = "basic", scopes = Array("post"))),
tags = Array("loot"), tags = Array("loot"),
) )
def modifyLoot: Route = def modifyLoot: Route =
@ -164,7 +174,12 @@ class LootEndpoint(override val storage: ActorRef[Message], override val auth: A
summary = "suggest loot", summary = "suggest loot",
description = "Suggest loot piece to party", description = "Suggest loot piece to party",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
), ),
requestBody = new RequestBody( requestBody = new RequestBody(
description = "piece description", description = "piece description",
@ -202,7 +217,7 @@ class LootEndpoint(override val storage: ActorRef[Message], override val auth: A
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
), ),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))), security = Array(new SecurityRequirement(name = "basic", scopes = Array("get"))),
tags = Array("loot"), tags = Array("loot"),
) )
def suggestLoot: Route = def suggestLoot: Route =

View File

@ -40,7 +40,7 @@ class PartyEndpoint(
with JsonSupport with JsonSupport
with HttpHandler { with HttpHandler {
def route: Route = getPartyDescription ~ modifyPartyDescription def routes: Route = getPartyDescription ~ modifyPartyDescription
@GET @GET
@Path("party/{partyId}/description") @Path("party/{partyId}/description")
@ -49,7 +49,12 @@ class PartyEndpoint(
summary = "get party description", summary = "get party description",
description = "Return the party description", description = "Return the party description",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
), ),
responses = Array( responses = Array(
new ApiResponse( new ApiResponse(
@ -73,7 +78,7 @@ class PartyEndpoint(
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
), ),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))), security = Array(new SecurityRequirement(name = "basic", scopes = Array("get"))),
tags = Array("party"), tags = Array("party"),
) )
def getPartyDescription: Route = def getPartyDescription: Route =
@ -96,7 +101,12 @@ class PartyEndpoint(
summary = "modify party description", summary = "modify party description",
description = "Edit party description", description = "Edit party description",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
), ),
requestBody = new RequestBody( requestBody = new RequestBody(
description = "new party description", description = "new party description",
@ -126,7 +136,7 @@ class PartyEndpoint(
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
), ),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))), security = Array(new SecurityRequirement(name = "basic", scopes = Array("post"))),
tags = Array("party"), tags = Array("party"),
) )
def modifyPartyDescription: Route = def modifyPartyDescription: Route =

View File

@ -41,7 +41,7 @@ class PlayerEndpoint(
with JsonSupport with JsonSupport
with HttpHandler { with HttpHandler {
def route: Route = getParty ~ getPartyStats ~ modifyParty def routes: Route = getParty ~ getPartyStats ~ modifyParty
@GET @GET
@Path("party/{partyId}") @Path("party/{partyId}")
@ -50,7 +50,12 @@ class PlayerEndpoint(
summary = "get party", summary = "get party",
description = "Return the players who belong to the party", description = "Return the players who belong to the party",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
new Parameter( new Parameter(
name = "nick", name = "nick",
in = ParameterIn.QUERY, in = ParameterIn.QUERY,
@ -85,7 +90,7 @@ class PlayerEndpoint(
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
), ),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))), security = Array(new SecurityRequirement(name = "basic", scopes = Array("get"))),
tags = Array("party"), tags = Array("party"),
) )
def getParty: Route = def getParty: Route =
@ -111,7 +116,12 @@ class PlayerEndpoint(
summary = "get party statistics", summary = "get party statistics",
description = "Return the party statistics", description = "Return the party statistics",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
new Parameter( new Parameter(
name = "nick", name = "nick",
in = ParameterIn.QUERY, in = ParameterIn.QUERY,
@ -146,7 +156,7 @@ class PlayerEndpoint(
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
), ),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))), security = Array(new SecurityRequirement(name = "basic", scopes = Array("get"))),
tags = Array("party"), tags = Array("party"),
) )
def getPartyStats: Route = def getPartyStats: Route =
@ -172,7 +182,12 @@ class PlayerEndpoint(
summary = "modify party", summary = "modify party",
description = "Add or remove a player from party list", description = "Add or remove a player from party list",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
), ),
requestBody = new RequestBody( requestBody = new RequestBody(
description = "player description", description = "player description",
@ -202,7 +217,7 @@ class PlayerEndpoint(
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
), ),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))), security = Array(new SecurityRequirement(name = "basic", scopes = Array("post"))),
tags = Array("party"), tags = Array("party"),
) )
def modifyParty: Route = def modifyParty: Route =

View File

@ -32,14 +32,15 @@ class RootApiV1Endpoint(
private val lootEndpoint = new LootEndpoint(storage, auth) private val lootEndpoint = new LootEndpoint(storage, auth)
private val partyEndpoint = new PartyEndpoint(storage, provider, auth) private val partyEndpoint = new PartyEndpoint(storage, provider, auth)
private val playerEndpoint = new PlayerEndpoint(storage, provider, auth) private val playerEndpoint = new PlayerEndpoint(storage, provider, auth)
private val statusEndpoint = new StatusEndpoint
private val typesEndpoint = new TypesEndpoint(config) private val typesEndpoint = new TypesEndpoint(config)
private val userEndpoint = new UserEndpoint(storage, auth) private val userEndpoint = new UserEndpoint(storage, auth)
def route: Route = def routes: Route =
handleExceptions(exceptionHandler) { handleExceptions(exceptionHandler) {
handleRejections(rejectionHandler) { handleRejections(rejectionHandler) {
biSEndpoint.route ~ lootEndpoint.route ~ partyEndpoint.route ~ biSEndpoint.routes ~ lootEndpoint.routes ~ partyEndpoint.routes ~ playerEndpoint.routes ~
playerEndpoint.route ~ typesEndpoint.route ~ userEndpoint.route statusEndpoint.routes ~ typesEndpoint.routes ~ userEndpoint.routes
} }
} }
} }

View File

@ -0,0 +1,54 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.media.{Content, Schema}
import io.swagger.v3.oas.annotations.responses.ApiResponse
import jakarta.ws.rs._
import me.arcanis.ffxivbis.http.api.v1.json._
@Path("/api/v1")
class StatusEndpoint extends JsonSupport {
def routes: Route = getServerStatus
@GET
@Path("status")
@Produces(value = Array("application/json"))
@Operation(
summary = "server status",
description = "Returns the server status descriptor",
responses = Array(
new ApiResponse(
responseCode = "200",
description = "Service status descriptor",
content = Array(new Content(schema = new Schema(implementation = classOf[StatusModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
),
tags = Array("status"),
)
def getServerStatus: Route =
path("status") {
get {
complete {
StatusModel(
version = Option(getClass.getPackage.getImplementationVersion),
)
}
}
}
}

View File

@ -21,7 +21,7 @@ import me.arcanis.ffxivbis.models._
@Path("/api/v1") @Path("/api/v1")
class TypesEndpoint(config: Config) extends JsonSupport { class TypesEndpoint(config: Config) extends JsonSupport {
def route: Route = getAllJobs ~ getJobs ~ getPermissions ~ getPieces ~ getPieceTypes ~ getPriority def routes: Route = getAllJobs ~ getJobs ~ getPermissions ~ getPieces ~ getPieceTypes ~ getPriority
@GET @GET
@Path("types/jobs/all") @Path("types/jobs/all")

View File

@ -36,9 +36,9 @@ class UserEndpoint(override val storage: ActorRef[Message], override val auth: A
with Authorization with Authorization
with JsonSupport { with JsonSupport {
def route: Route = createParty ~ createUser ~ deleteUser ~ getUsers ~ getUsersCurrent def routes: Route = createParty ~ createUser ~ deleteUser ~ getUsers ~ getUsersCurrent
@PUT @POST
@Path("party") @Path("party")
@Consumes(value = Array("application/json")) @Consumes(value = Array("application/json"))
@Operation( @Operation(
@ -76,7 +76,7 @@ class UserEndpoint(override val storage: ActorRef[Message], override val auth: A
def createParty: Route = def createParty: Route =
path("party") { path("party") {
extractExecutionContext { implicit executionContext => extractExecutionContext { implicit executionContext =>
put { post {
entity(as[UserModel]) { user => entity(as[UserModel]) { user =>
onSuccess(newPartyId) { partyId => onSuccess(newPartyId) { partyId =>
val admin = user.toUser.copy(partyId = partyId, permission = Permission.admin) val admin = user.toUser.copy(partyId = partyId, permission = Permission.admin)
@ -96,7 +96,12 @@ class UserEndpoint(override val storage: ActorRef[Message], override val auth: A
summary = "create new user", summary = "create new user",
description = "Add an user to the specified party", description = "Add an user to the specified party",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
), ),
requestBody = new RequestBody( requestBody = new RequestBody(
description = "user description", description = "user description",
@ -126,7 +131,7 @@ class UserEndpoint(override val storage: ActorRef[Message], override val auth: A
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
), ),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))), security = Array(new SecurityRequirement(name = "basic", scopes = Array("admin"))),
tags = Array("users"), tags = Array("users"),
) )
def createUser: Route = def createUser: Route =
@ -151,7 +156,12 @@ class UserEndpoint(override val storage: ActorRef[Message], override val auth: A
summary = "remove user", summary = "remove user",
description = "Remove an user from the specified party", description = "Remove an user from the specified party",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
new Parameter(name = "username", in = ParameterIn.PATH, description = "username to remove", example = "siuan"), new Parameter(name = "username", in = ParameterIn.PATH, description = "username to remove", example = "siuan"),
), ),
responses = Array( responses = Array(
@ -172,7 +182,7 @@ class UserEndpoint(override val storage: ActorRef[Message], override val auth: A
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
), ),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))), security = Array(new SecurityRequirement(name = "basic", scopes = Array("admin"))),
tags = Array("users"), tags = Array("users"),
) )
def deleteUser: Route = def deleteUser: Route =
@ -195,7 +205,12 @@ class UserEndpoint(override val storage: ActorRef[Message], override val auth: A
summary = "get users", summary = "get users",
description = "Return the list of users belong to party", description = "Return the list of users belong to party",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
), ),
responses = Array( responses = Array(
new ApiResponse( new ApiResponse(
@ -223,7 +238,7 @@ class UserEndpoint(override val storage: ActorRef[Message], override val auth: A
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
), ),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))), security = Array(new SecurityRequirement(name = "basic", scopes = Array("get"))),
tags = Array("users"), tags = Array("users"),
) )
def getUsers: Route = def getUsers: Route =
@ -246,7 +261,12 @@ class UserEndpoint(override val storage: ActorRef[Message], override val auth: A
summary = "get current user", summary = "get current user",
description = "Return the current user descriptor", description = "Return the current user descriptor",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
), ),
responses = Array( responses = Array(
new ApiResponse( new ApiResponse(
@ -270,7 +290,7 @@ class UserEndpoint(override val storage: ActorRef[Message], override val auth: A
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
), ),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))), security = Array(new SecurityRequirement(name = "basic", scopes = Array("admin"))),
tags = Array("users"), tags = Array("users"),
) )
def getUsersCurrent: Route = def getUsersCurrent: Route =

View File

@ -9,5 +9,6 @@
package me.arcanis.ffxivbis.http.api.v1.json package me.arcanis.ffxivbis.http.api.v1.json
object ApiAction extends Enumeration { object ApiAction extends Enumeration {
val add, remove = Value val add, remove = Value
} }

View File

@ -16,12 +16,12 @@ import java.time.Instant
trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol { trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol {
private def enumFormat[E <: Enumeration](enum: E): RootJsonFormat[E#Value] = private def enumFormat[E <: Enumeration](enumeration: E): RootJsonFormat[E#Value] =
new RootJsonFormat[E#Value] { new RootJsonFormat[E#Value] {
override def write(obj: E#Value): JsValue = obj.toString.toJson override def write(obj: E#Value): JsValue = obj.toString.toJson
override def read(json: JsValue): E#Value = json match { override def read(json: JsValue): E#Value = json match {
case JsNumber(value) => enum(value.toInt) case JsNumber(value) => enumeration(value.toInt)
case JsString(name) => enum.withName(name) case JsString(name) => enumeration.withName(name)
case other => deserializationError(s"String or number expected, got $other") case other => deserializationError(s"String or number expected, got $other")
} }
} }
@ -52,5 +52,6 @@ trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol {
implicit val playerBiSLinkFormat: RootJsonFormat[PlayerBiSLinkModel] = jsonFormat2(PlayerBiSLinkModel.apply) implicit val playerBiSLinkFormat: RootJsonFormat[PlayerBiSLinkModel] = jsonFormat2(PlayerBiSLinkModel.apply)
implicit val playerIdWithCountersFormat: RootJsonFormat[PlayerIdWithCountersModel] = implicit val playerIdWithCountersFormat: RootJsonFormat[PlayerIdWithCountersModel] =
jsonFormat9(PlayerIdWithCountersModel.apply) jsonFormat9(PlayerIdWithCountersModel.apply)
implicit val statusFormat: RootJsonFormat[StatusModel] = jsonFormat1(StatusModel.apply)
implicit val userFormat: RootJsonFormat[UserModel] = jsonFormat4(UserModel.apply) implicit val userFormat: RootJsonFormat[UserModel] = jsonFormat4(UserModel.apply)
} }

View File

@ -12,9 +12,11 @@ import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.PartyDescription import me.arcanis.ffxivbis.models.PartyDescription
case class PartyDescriptionModel( case class PartyDescriptionModel(
@Schema(description = "party id", required = true, example = "abcdefgh") partyId: String, @Schema(description = "party id", required = true, example = "o3KicHQPW5b0JcOm5yI3") partyId: String,
@Schema(description = "party name") partyAlias: Option[String] @Schema(description = "party name") partyAlias: Option[String]
) { ) extends Validator {
require(partyAlias.forall(isValidString), stringMatchError("Party alias"))
def toDescription: PartyDescription = PartyDescription(partyId, partyAlias) def toDescription: PartyDescription = PartyDescription(partyId, partyAlias)
} }

View File

@ -10,4 +10,6 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
case class PartyIdModel(@Schema(description = "party id", required = true, example = "abcdefgh") partyId: String) case class PartyIdModel(
@Schema(description = "party id", required = true, example = "o3KicHQPW5b0JcOm5yI3") partyId: String
)

View File

@ -17,4 +17,7 @@ case class PlayerBiSLinkModel(
example = "https://ffxiv.ariyala.com/19V5R" example = "https://ffxiv.ariyala.com/19V5R"
) link: String, ) link: String,
@Schema(description = "player description", required = true) playerId: PlayerIdModel @Schema(description = "player description", required = true) playerId: PlayerIdModel
) ) extends Validator {
require(isValidString(link), stringMatchError("BiS link"))
}

View File

@ -12,10 +12,14 @@ import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{Job, PlayerId} import me.arcanis.ffxivbis.models.{Job, PlayerId}
case class PlayerIdModel( case class PlayerIdModel(
@Schema(description = "unique party ID. Required in responses", example = "abcdefgh") partyId: Option[String], @Schema(description = "unique party ID. Required in responses", example = "o3KicHQPW5b0JcOm5yI3") partyId: Option[
String
],
@Schema(description = "job name", required = true, example = "DNC") job: String, @Schema(description = "job name", required = true, example = "DNC") job: String,
@Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String @Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String
) { ) extends Validator {
require(isValidString(nick), stringMatchError("Player name"))
def withPartyId(partyId: String): PlayerId = def withPartyId(partyId: String): PlayerId =
PlayerId(partyId, Job.withName(job), nick) PlayerId(partyId, Job.withName(job), nick)

View File

@ -12,7 +12,7 @@ import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.PlayerIdWithCounters import me.arcanis.ffxivbis.models.PlayerIdWithCounters
case class PlayerIdWithCountersModel( case class PlayerIdWithCountersModel(
@Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: String, @Schema(description = "unique party ID", required = true, example = "o3KicHQPW5b0JcOm5yI3") partyId: String,
@Schema(description = "job name", required = true, example = "DNC") job: String, @Schema(description = "job name", required = true, example = "DNC") job: String,
@Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String, @Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String,
@Schema(description = "is piece required by player or not", required = true) isRequired: Boolean, @Schema(description = "is piece required by player or not", required = true) isRequired: Boolean,

View File

@ -12,16 +12,22 @@ import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{BiS, Job, Player} import me.arcanis.ffxivbis.models.{BiS, Job, Player}
case class PlayerModel( case class PlayerModel(
@Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: String, @Schema(description = "unique party ID", required = true, example = "o3KicHQPW5b0JcOm5yI3") partyId: String,
@Schema(description = "job name", required = true, example = "DNC") job: String, @Schema(description = "job name", required = true, example = "DNC") job: String,
@Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String, @Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String,
@Schema(description = "pieces in best in slot") bis: Option[Seq[PieceModel]], @Schema(description = "pieces in best in slot") bis: Option[Seq[PieceModel]],
@Schema(description = "looted pieces") loot: Option[Seq[LootModel]], @Schema(description = "looted pieces") loot: Option[Seq[LootModel]],
@Schema(description = "link to best in slot", example = "https://ffxiv.ariyala.com/19V5R") link: Option[String], @Schema(description = "link to best in slot", example = "https://ffxiv.ariyala.com/19V5R") link: Option[String],
@Schema(description = "player loot priority", `type` = "number") priority: Option[Int], @Schema(description = "player loot priority", `type` = "number") priority: Option[Int],
@Schema(description = "count of looted pieces which are parts of best in slot") lootCountBiS: Option[Int], @Schema(
description = "count of looted pieces which are parts of best in slot",
`type` = "number"
) lootCountBiS: Option[Int],
@Schema(description = "total count of looted pieces", `type` = "number") lootCountTotal: Option[Int], @Schema(description = "total count of looted pieces", `type` = "number") lootCountTotal: Option[Int],
) { ) extends Validator {
require(isValidString(nick), stringMatchError("Player name"))
require(link.forall(isValidString), stringMatchError("BiS link"))
def toPlayer: Player = def toPlayer: Player =
Player( Player(

View File

@ -0,0 +1,13 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
case class StatusModel(@Schema(description = "server version") version: Option[String])

View File

@ -12,23 +12,30 @@ import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{Permission, User} import me.arcanis.ffxivbis.models.{Permission, User}
case class UserModel( case class UserModel(
@Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: String, @Schema(description = "unique party ID", required = true, example = "o3KicHQPW5b0JcOm5yI3") partyId: String,
@Schema(description = "username to login to party", required = true, example = "siuan") username: String, @Schema(description = "username to login to party", required = true, example = "siuan") username: String,
@Schema(description = "password to login to party", required = true, example = "pa55w0rd") password: String, @Schema(description = "password to login to party, required for user editing", example = "pa55w0rd") password: Option[
String
],
@Schema( @Schema(
description = "user permission", description = "user permission",
defaultValue = "get", defaultValue = "get",
`type` = "string", `type` = "string",
allowableValues = Array("get", "post", "admin") allowableValues = Array("get", "post", "admin")
) permission: Option[Permission.Value] = None ) permission: Option[Permission.Value] = None
) { ) extends Validator {
require(isValidString(username), stringMatchError("Username"))
require(password.forall(_.nonEmpty), "Password must not be empty")
def toUser: User = def toUser: User =
User(partyId, username, password, permission.getOrElse(Permission.get)) password.fold(throw new IllegalArgumentException("Password must noot be empty"))(
User(partyId, username, _, permission.getOrElse(Permission.get))
)
} }
object UserModel { object UserModel {
def fromUser(user: User): UserModel = def fromUser(user: User): UserModel =
UserModel(user.partyId, user.username, "", Some(user.permission)) UserModel(user.partyId, user.username, None, Some(user.permission))
} }

View File

@ -0,0 +1,9 @@
package me.arcanis.ffxivbis.http.api.v1.json
import me.arcanis.ffxivbis.http.ValidatorHelper
trait Validator extends ValidatorHelper {
def stringMatchError(what: String): String =
s"$what must contain only letters or digits or one of (${ValidatorHelper.VALID_CHARACTERS.mkString(", ")})"
}

View File

@ -1,10 +1,19 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.helpers package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.actor.typed.{ActorRef, Scheduler} import akka.actor.typed.{ActorRef, Scheduler}
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.http.api.v1.json.ApiAction import me.arcanis.ffxivbis.http.api.v1.json.ApiAction
import me.arcanis.ffxivbis.messages._ import me.arcanis.ffxivbis.messages.DatabaseMessage._
import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId} import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId}
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
@ -36,13 +45,15 @@ trait BiSHelper extends BisProviderHelper {
timeout: Timeout, timeout: Timeout,
scheduler: Scheduler scheduler: Scheduler
): Future[Unit] = ): Future[Unit] =
storage.ask(RemovePiecesFromBiS(playerId, _)).flatMap { _ => storage
.ask(RemovePiecesFromBiS(playerId, _))
.flatMap { _ =>
downloadBiS(link, playerId.job) downloadBiS(link, playerId.job)
.flatMap { bis => .flatMap { bis =>
Future.traverse(bis.pieces)(addPieceBiS(playerId, _)) Future.traverse(bis.pieces)(addPieceBiS(playerId, _))
} }
.map(_ => ())
} }
.flatMap(_ => storage.ask(UpdateBiSLink(playerId, link, _)))
def removePieceBiS(playerId: PlayerId, piece: Piece)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] = def removePieceBiS(playerId: PlayerId, piece: Piece)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(RemovePieceFromBiS(playerId, piece, _)) storage.ask(RemovePieceFromBiS(playerId, piece, _))

View File

@ -1,9 +1,18 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.helpers package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.actor.typed.{ActorRef, Scheduler} import akka.actor.typed.{ActorRef, Scheduler}
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, DownloadBiS} import me.arcanis.ffxivbis.messages.BiSProviderMessage
import me.arcanis.ffxivbis.messages.BiSProviderMessage._
import me.arcanis.ffxivbis.models.{BiS, Job} import me.arcanis.ffxivbis.models.{BiS, Job}
import scala.concurrent.Future import scala.concurrent.Future
@ -12,6 +21,6 @@ trait BisProviderHelper {
def provider: ActorRef[BiSProviderMessage] def provider: ActorRef[BiSProviderMessage]
def downloadBiS(link: String, job: Job.Job)(implicit timeout: Timeout, scheduler: Scheduler): Future[BiS] = def downloadBiS(link: String, job: Job)(implicit timeout: Timeout, scheduler: Scheduler): Future[BiS] =
provider.ask(DownloadBiS(link, job, _)) provider.ask(DownloadBiS(link, job, _))
} }

View File

@ -1,10 +1,19 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.helpers package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.actor.typed.{ActorRef, Scheduler} import akka.actor.typed.{ActorRef, Scheduler}
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.http.api.v1.json.ApiAction import me.arcanis.ffxivbis.http.api.v1.json.ApiAction
import me.arcanis.ffxivbis.messages._ import me.arcanis.ffxivbis.messages.DatabaseMessage._
import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId, PlayerIdWithCounters} import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId, PlayerIdWithCounters}
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
@ -25,8 +34,8 @@ trait LootHelper {
): Future[Unit] = ): Future[Unit] =
(action, maybeFree) match { (action, maybeFree) match {
case (ApiAction.add, Some(isFreeLoot)) => addPieceLoot(playerId, piece, isFreeLoot) case (ApiAction.add, Some(isFreeLoot)) => addPieceLoot(playerId, piece, isFreeLoot)
case (ApiAction.remove, _) => removePieceLoot(playerId, piece) case (ApiAction.remove, Some(isFreeLoot)) => removePieceLoot(playerId, piece, isFreeLoot)
case _ => throw new IllegalArgumentException(s"Invalid combinantion of action $action and fee loot $maybeFree") case _ => throw new IllegalArgumentException("Loot modification must always contain `isFreeLoot` field")
} }
def loot(partyId: String, playerId: Option[PlayerId])(implicit def loot(partyId: String, playerId: Option[PlayerId])(implicit
@ -35,8 +44,11 @@ trait LootHelper {
): Future[Seq[Player]] = ): Future[Seq[Player]] =
storage.ask(GetLoot(partyId, playerId, _)) storage.ask(GetLoot(partyId, playerId, _))
def removePieceLoot(playerId: PlayerId, piece: Piece)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] = def removePieceLoot(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean)(implicit
storage.ask(RemovePieceFrom(playerId, piece, _)) timeout: Timeout,
scheduler: Scheduler
): Future[Unit] =
storage.ask(RemovePieceFrom(playerId, piece, isFreeLoot, _))
def suggestPiece(partyId: String, piece: Piece)(implicit def suggestPiece(partyId: String, piece: Piece)(implicit
executionContext: ExecutionContext, executionContext: ExecutionContext,

View File

@ -1,10 +1,19 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.helpers package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.actor.typed.{ActorRef, Scheduler} import akka.actor.typed.{ActorRef, Scheduler}
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.http.api.v1.json.ApiAction import me.arcanis.ffxivbis.http.api.v1.json.ApiAction
import me.arcanis.ffxivbis.messages._ import me.arcanis.ffxivbis.messages.DatabaseMessage._
import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.{PartyDescription, Player, PlayerId} import me.arcanis.ffxivbis.models.{PartyDescription, Player, PlayerId}
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
@ -18,15 +27,15 @@ trait PlayerHelper extends BisProviderHelper {
)(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Unit] = )(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage storage
.ask(ref => AddPlayer(player, ref)) .ask(ref => AddPlayer(player, ref))
.map { res => .map { _ =>
player.link.map(_.trim).filter(_.nonEmpty) match { player.link.map(_.trim).filter(_.nonEmpty) match {
case Some(link) => case Some(link) =>
downloadBiS(link, player.job) downloadBiS(link, player.job)
.map { bis => .map { bis =>
bis.pieces.map(piece => storage.ask(AddPieceToBis(player.playerId, piece, _))) bis.pieces.map(piece => storage.ask(AddPieceToBis(player.playerId, piece, _)))
} }
.map(_ => res) .flatMap(_ => storage.ask(UpdateBiSLink(player.playerId, link, _)))
case None => Future.successful(res) case None => Future.successful(())
} }
} }
.flatten .flatten

View File

@ -1,9 +1,19 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.helpers package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.actor.typed.{ActorRef, Scheduler} import akka.actor.typed.{ActorRef, Scheduler}
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.messages._ import me.arcanis.ffxivbis.messages.ControlMessage.GetNewPartyId
import me.arcanis.ffxivbis.messages.DatabaseMessage._
import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.User import me.arcanis.ffxivbis.models.User
import scala.concurrent.Future import scala.concurrent.Future

View File

@ -15,7 +15,7 @@ import me.arcanis.ffxivbis.http.{Authorization, AuthorizationProvider}
class RootView(override val auth: AuthorizationProvider) extends Authorization { class RootView(override val auth: AuthorizationProvider) extends Authorization {
def route: Route = getBiS ~ getIndex ~ getLoot ~ getParty ~ getUsers def routes: Route = getBiS ~ getIndex ~ getLoot ~ getParty ~ getUsers
def getBiS: Route = def getBiS: Route =
path("party" / Segment / "bis") { partyId: String => path("party" / Segment / "bis") { partyId: String =>

View File

@ -13,7 +13,10 @@ import me.arcanis.ffxivbis.models.{BiS, Job}
sealed trait BiSProviderMessage sealed trait BiSProviderMessage
case class DownloadBiS(link: String, job: Job.Job, replyTo: ActorRef[BiS]) extends BiSProviderMessage { object BiSProviderMessage {
case class DownloadBiS(link: String, job: Job, replyTo: ActorRef[BiS]) extends BiSProviderMessage {
require(link.nonEmpty && link.trim == link, "Link must be not empty and contain no spaces") require(link.nonEmpty && link.trim == link, "Link must be not empty and contain no spaces")
}
} }

View File

@ -11,8 +11,13 @@ package me.arcanis.ffxivbis.messages
import akka.actor.typed.ActorRef import akka.actor.typed.ActorRef
import me.arcanis.ffxivbis.models.Party import me.arcanis.ffxivbis.models.Party
case class ForgetParty(partyId: String) extends Message sealed trait ControlMessage extends Message
case class GetNewPartyId(replyTo: ActorRef[String]) extends Message object ControlMessage {
case class StoreParty(partyId: String, party: Party) extends Message case class ForgetParty(partyId: String) extends ControlMessage
case class GetNewPartyId(replyTo: ActorRef[String]) extends ControlMessage
case class StoreParty(partyId: String, party: Party) extends ControlMessage
}

View File

@ -8,90 +8,124 @@
*/ */
package me.arcanis.ffxivbis.messages package me.arcanis.ffxivbis.messages
import akka.actor.typed.{ActorRef, Behavior} import akka.actor.typed.ActorRef
import me.arcanis.ffxivbis.models._ import me.arcanis.ffxivbis.models._
import me.arcanis.ffxivbis.service.LootSelector import me.arcanis.ffxivbis.service.LootSelector
sealed trait DatabaseMessage extends Message { sealed trait DatabaseMessage extends Message {
def partyId: String def partyId: String
def isReadOnly: Boolean
} }
object DatabaseMessage { object DatabaseMessage {
type Handler = PartialFunction[DatabaseMessage, Behavior[DatabaseMessage]] // bis handler
} trait BisDatabaseMessage extends DatabaseMessage
// bis handler case class AddPieceToBis(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends BisDatabaseMessage {
trait BisDatabaseMessage extends DatabaseMessage override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
}
case class AddPieceToBis(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends BisDatabaseMessage { case class GetBiS(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]])
override def partyId: String = playerId.partyId extends BisDatabaseMessage {
} override val isReadOnly: Boolean = true
}
case class GetBiS(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]]) case class RemovePieceFromBiS(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends BisDatabaseMessage {
extends BisDatabaseMessage override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
}
case class RemovePieceFromBiS(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends BisDatabaseMessage { case class RemovePiecesFromBiS(playerId: PlayerId, replyTo: ActorRef[Unit]) extends BisDatabaseMessage {
override def partyId: String = playerId.partyId override val partyId: String = playerId.partyId
} override val isReadOnly: Boolean = false
}
case class RemovePiecesFromBiS(playerId: PlayerId, replyTo: ActorRef[Unit]) extends BisDatabaseMessage { // loot handler
override def partyId: String = playerId.partyId trait LootDatabaseMessage extends DatabaseMessage
}
// loot handler case class AddPieceTo(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean, replyTo: ActorRef[Unit])
trait LootDatabaseMessage extends DatabaseMessage
case class AddPieceTo(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean, replyTo: ActorRef[Unit])
extends LootDatabaseMessage { extends LootDatabaseMessage {
override def partyId: String = playerId.partyId override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
}
case class GetLoot(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]])
extends LootDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class RemovePieceFrom(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean, replyTo: ActorRef[Unit])
extends LootDatabaseMessage {
override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
}
case class SuggestLoot(partyId: String, piece: Piece, replyTo: ActorRef[LootSelector.LootSelectorResult])
extends LootDatabaseMessage {
override val isReadOnly: Boolean = true
}
// party handler
trait PartyDatabaseMessage extends DatabaseMessage
case class AddPlayer(player: Player, replyTo: ActorRef[Unit]) extends PartyDatabaseMessage {
override val partyId: String = player.partyId
override val isReadOnly: Boolean = false
}
case class GetParty(partyId: String, replyTo: ActorRef[Party]) extends PartyDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class GetPartyDescription(partyId: String, replyTo: ActorRef[PartyDescription]) extends PartyDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class GetPlayer(playerId: PlayerId, replyTo: ActorRef[Option[Player]]) extends PartyDatabaseMessage {
override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = true
}
case class RemovePlayer(playerId: PlayerId, replyTo: ActorRef[Unit]) extends PartyDatabaseMessage {
override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
}
case class UpdateBiSLink(playerId: PlayerId, link: String, actorRef: ActorRef[Unit]) extends PartyDatabaseMessage {
override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
}
case class UpdateParty(partyDescription: PartyDescription, replyTo: ActorRef[Unit]) extends PartyDatabaseMessage {
override val partyId: String = partyDescription.partyId
override val isReadOnly: Boolean = false
}
// user handler
trait UserDatabaseMessage extends DatabaseMessage
case class AddUser(user: User, isHashedPassword: Boolean, replyTo: ActorRef[Unit]) extends UserDatabaseMessage {
override val partyId: String = user.partyId
override val isReadOnly: Boolean = false
}
case class DeleteUser(partyId: String, username: String, replyTo: ActorRef[Unit]) extends UserDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class Exists(partyId: String, replyTo: ActorRef[Boolean]) extends UserDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class GetUser(partyId: String, username: String, replyTo: ActorRef[Option[User]]) extends UserDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class GetUsers(partyId: String, replyTo: ActorRef[Seq[User]]) extends UserDatabaseMessage {
override val isReadOnly: Boolean = true
}
} }
case class GetLoot(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]])
extends LootDatabaseMessage
case class RemovePieceFrom(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends LootDatabaseMessage {
override def partyId: String = playerId.partyId
}
case class SuggestLoot(partyId: String, piece: Piece, replyTo: ActorRef[LootSelector.LootSelectorResult])
extends LootDatabaseMessage
// party handler
trait PartyDatabaseMessage extends DatabaseMessage
case class AddPlayer(player: Player, replyTo: ActorRef[Unit]) extends PartyDatabaseMessage {
override def partyId: String = player.partyId
}
case class GetParty(partyId: String, replyTo: ActorRef[Party]) extends PartyDatabaseMessage
case class GetPartyDescription(partyId: String, replyTo: ActorRef[PartyDescription]) extends PartyDatabaseMessage
case class GetPlayer(playerId: PlayerId, replyTo: ActorRef[Option[Player]]) extends PartyDatabaseMessage {
override def partyId: String = playerId.partyId
}
case class RemovePlayer(playerId: PlayerId, replyTo: ActorRef[Unit]) extends PartyDatabaseMessage {
override def partyId: String = playerId.partyId
}
case class UpdateParty(partyDescription: PartyDescription, replyTo: ActorRef[Unit]) extends PartyDatabaseMessage {
override def partyId: String = partyDescription.partyId
}
// user handler
trait UserDatabaseMessage extends DatabaseMessage
case class AddUser(user: User, isHashedPassword: Boolean, replyTo: ActorRef[Unit]) extends UserDatabaseMessage {
override def partyId: String = user.partyId
}
case class DeleteUser(partyId: String, username: String, replyTo: ActorRef[Unit]) extends UserDatabaseMessage
case class Exists(partyId: String, replyTo: ActorRef[Boolean]) extends UserDatabaseMessage
case class GetUser(partyId: String, username: String, replyTo: ActorRef[Option[User]]) extends UserDatabaseMessage
case class GetUsers(partyId: String, replyTo: ActorRef[Seq[User]]) extends UserDatabaseMessage

View File

@ -11,14 +11,14 @@ package me.arcanis.ffxivbis.models
case class BiS(pieces: Seq[Piece]) { case class BiS(pieces: Seq[Piece]) {
def hasPiece(piece: Piece): Boolean = piece match { def hasPiece(piece: Piece): Boolean = piece match {
case upgrade: PieceUpgrade => upgrades.contains(upgrade) case upgrade: Piece.PieceUpgrade => upgrades.contains(upgrade)
case _ => pieces.contains(piece) case _ => pieces.contains(piece)
} }
def upgrades: Map[PieceUpgrade, Int] = def upgrades: Map[Piece.PieceUpgrade, Int] =
pieces pieces
.groupBy(_.upgrade) .groupBy(_.upgrade)
.foldLeft(Map.empty[PieceUpgrade, Int]) { .foldLeft(Map.empty[Piece.PieceUpgrade, Int]) {
case (acc, (Some(k), v)) => acc + (k -> v.size) case (acc, (Some(k), v)) => acc + (k -> v.size)
case (acc, _) => acc case (acc, _) => acc
} }
@ -43,5 +43,5 @@ case class BiS(pieces: Seq[Piece]) {
object BiS { object BiS {
def empty: BiS = BiS(Seq.empty) val empty: BiS = BiS(Seq.empty)
} }

View File

@ -8,6 +8,26 @@
*/ */
package me.arcanis.ffxivbis.models package me.arcanis.ffxivbis.models
sealed trait Job extends Equals {
def leftSide: Job.LeftSide
def rightSide: Job.RightSide
// conversion to string to avoid recursion
override def canEqual(that: Any): Boolean = that.isInstanceOf[Job]
override def equals(obj: Any): Boolean = {
def equality(objRepr: String): Boolean = objRepr match {
case _ if objRepr == Job.AnyJob.toString => true
case _ if this.toString == Job.AnyJob.toString => true
case _ => this.toString == objRepr
}
canEqual(obj) && equality(obj.toString)
}
}
object Job { object Job {
sealed trait RightSide sealed trait RightSide
@ -26,54 +46,38 @@ object Job {
object BodyTanks extends LeftSide object BodyTanks extends LeftSide
object BodyRanges extends LeftSide object BodyRanges extends LeftSide
sealed trait Job extends Equals {
def leftSide: LeftSide
def rightSide: RightSide
// conversion to string to avoid recursion
override def canEqual(that: Any): Boolean = that.isInstanceOf[Job]
override def equals(obj: Any): Boolean = {
def equality(objRepr: String): Boolean = objRepr match {
case _ if objRepr == AnyJob.toString => true
case _ if this.toString == AnyJob.toString => true
case _ => this.toString == objRepr
}
canEqual(obj) && equality(obj.toString)
}
}
case object AnyJob extends Job { case object AnyJob extends Job {
val leftSide: LeftSide = null override val leftSide: LeftSide = null
val rightSide: RightSide = null override val rightSide: RightSide = null
} }
trait Casters extends Job { trait Casters extends Job {
val leftSide: LeftSide = BodyCasters override val leftSide: LeftSide = BodyCasters
val rightSide: RightSide = AccessoriesInt override val rightSide: RightSide = AccessoriesInt
} }
trait Healers extends Job { trait Healers extends Job {
val leftSide: LeftSide = BodyHealers override val leftSide: LeftSide = BodyHealers
val rightSide: RightSide = AccessoriesMnd override val rightSide: RightSide = AccessoriesMnd
} }
trait Mnks extends Job { trait Mnks extends Job {
val leftSide: LeftSide = BodyMnks override val leftSide: LeftSide = BodyMnks
val rightSide: RightSide = AccessoriesStr override val rightSide: RightSide = AccessoriesStr
} }
trait Drgs extends Job { trait Drgs extends Job {
val leftSide: LeftSide = BodyDrgs override val leftSide: LeftSide = BodyDrgs
val rightSide: RightSide = AccessoriesStr override val rightSide: RightSide = AccessoriesStr
}
trait Nins extends Job {
override val leftSide: LeftSide = BodyNins
override val rightSide: RightSide = AccessoriesDex
} }
trait Tanks extends Job { trait Tanks extends Job {
val leftSide: LeftSide = BodyTanks override val leftSide: LeftSide = BodyTanks
val rightSide: RightSide = AccessoriesVit override val rightSide: RightSide = AccessoriesVit
} }
trait Ranges extends Job { trait Ranges extends Job {
val leftSide: LeftSide = BodyRanges override val leftSide: LeftSide = BodyRanges
val rightSide: RightSide = AccessoriesDex override val rightSide: RightSide = AccessoriesDex
} }
case object PLD extends Tanks case object PLD extends Tanks
@ -89,11 +93,9 @@ object Job {
case object MNK extends Mnks case object MNK extends Mnks
case object DRG extends Drgs case object DRG extends Drgs
case object RPR extends Drgs case object RPR extends Drgs
case object NIN extends Job { case object NIN extends Nins
val leftSide: LeftSide = BodyNins
val rightSide: RightSide = AccessoriesDex
}
case object SAM extends Mnks case object SAM extends Mnks
case object VPR extends Mnks
case object BRD extends Ranges case object BRD extends Ranges
case object MCH extends Ranges case object MCH extends Ranges
@ -102,12 +104,13 @@ object Job {
case object BLM extends Casters case object BLM extends Casters
case object SMN extends Casters case object SMN extends Casters
case object RDM extends Casters case object RDM extends Casters
case object PCT extends Casters
lazy val available: Seq[Job] = val available: Seq[Job] =
Seq(PLD, WAR, DRK, GNB, WHM, SCH, AST, SGE, MNK, DRG, RPR, NIN, SAM, BRD, MCH, DNC, BLM, SMN, RDM) Seq(PLD, WAR, DRK, GNB, WHM, SCH, AST, SGE, MNK, DRG, RPR, NIN, SAM, VPR, BRD, MCH, DNC, BLM, SMN, RDM, PCT)
lazy val availableWithAnyJob: Seq[Job] = available.prepended(AnyJob) val availableWithAnyJob: Seq[Job] = available.prepended(AnyJob)
def withName(job: String): Job.Job = def withName(job: String): Job =
availableWithAnyJob.find(_.toString.equalsIgnoreCase(job)) match { availableWithAnyJob.find(_.toString.equalsIgnoreCase(job)) match {
case Some(value) => value case Some(value) => value
case None if job.isEmpty => AnyJob case None if job.isEmpty => AnyJob

View File

@ -12,5 +12,5 @@ import java.time.Instant
case class Loot(playerId: Long, piece: Piece, timestamp: Instant, isFreeLoot: Boolean) { case class Loot(playerId: Long, piece: Piece, timestamp: Instant, isFreeLoot: Boolean) {
def isFreeLootToString: String = if (isFreeLoot) "yes" else "no" lazy val isFreeLootToInt: Int = if (isFreeLoot) 1 else 0
} }

View File

@ -14,6 +14,7 @@ import me.arcanis.ffxivbis.service.LootSelector
import scala.jdk.CollectionConverters._ import scala.jdk.CollectionConverters._
import scala.util.Random import scala.util.Random
import scala.util.control.NonFatal
case class Party(partyDescription: PartyDescription, rules: Seq[String], players: Map[PlayerId, Player]) case class Party(partyDescription: PartyDescription, rules: Seq[String], players: Map[PlayerId, Player])
extends StrictLogging { extends StrictLogging {
@ -29,7 +30,7 @@ case class Party(partyDescription: PartyDescription, rules: Seq[String], players
require(player.partyId == partyDescription.partyId, "player must belong to this party") require(player.partyId == partyDescription.partyId, "player must belong to this party")
copy(players = players + (player.playerId -> player)) copy(players = players + (player.playerId -> player))
} catch { } catch {
case exception: Exception => case NonFatal(exception) =>
logger.error("cannot add player", exception) logger.error("cannot add player", exception)
this this
} }

View File

@ -8,10 +8,7 @@
*/ */
package me.arcanis.ffxivbis.models package me.arcanis.ffxivbis.models
case class PartyDescription(partyId: String, partyAlias: Option[String]) { case class PartyDescription(partyId: String, partyAlias: Option[String])
def alias: String = partyAlias.getOrElse(partyId)
}
object PartyDescription { object PartyDescription {

View File

@ -10,20 +10,20 @@ package me.arcanis.ffxivbis.models
sealed trait Piece extends Equals { sealed trait Piece extends Equals {
def pieceType: PieceType.PieceType def pieceType: PieceType
def job: Job.Job def job: Job
def piece: String def piece: String
def withJob(other: Job.Job): Piece def withJob(other: Job): Piece
def upgrade: Option[PieceUpgrade] = { def upgrade: Option[Piece.PieceUpgrade] = {
val isTome = pieceType == PieceType.Tome val isTome = pieceType == PieceType.Tome
Some(this).collect { Some(this).collect {
case _: PieceAccessory if isTome => AccessoryUpgrade case _: Piece.PieceAccessory if isTome => Piece.AccessoryUpgrade
case _: PieceBody if isTome => BodyUpgrade case _: Piece.PieceBody if isTome => Piece.BodyUpgrade
case _: PieceWeapon if isTome => WeaponUpgrade case _: Piece.PieceWeapon if isTome => Piece.WeaponUpgrade
} }
} }
@ -31,59 +31,61 @@ sealed trait Piece extends Equals {
def strictEqual(obj: Any): Boolean = equals(obj) def strictEqual(obj: Any): Boolean = equals(obj)
} }
trait PieceAccessory extends Piece object Piece {
trait PieceBody extends Piece
trait PieceUpgrade extends Piece {
val pieceType: PieceType.PieceType = PieceType.Tome
val job: Job.Job = Job.AnyJob
def withJob(other: Job.Job): Piece = this
}
trait PieceWeapon extends Piece
case class Weapon(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceWeapon { trait PieceAccessory extends Piece
val piece: String = "weapon" trait PieceBody extends Piece
def withJob(other: Job.Job): Piece = copy(job = other) trait PieceUpgrade extends Piece {
} override val pieceType: PieceType = PieceType.Tome
override val job: Job = Job.AnyJob
override def withJob(other: Job): Piece = this
}
trait PieceWeapon extends Piece
case class Head(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody { case class Weapon(override val pieceType: PieceType, override val job: Job) extends PieceWeapon {
val piece: String = "head" override val piece: String = "weapon"
def withJob(other: Job.Job): Piece = copy(job = other) override def withJob(other: Job): Piece = copy(job = other)
} }
case class Body(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody {
val piece: String = "body"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Hands(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody {
val piece: String = "hands"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Legs(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody {
val piece: String = "legs"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Feet(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody {
val piece: String = "feet"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Ears(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceAccessory { case class Head(override val pieceType: PieceType, override val job: Job) extends PieceBody {
val piece: String = "ears" override val piece: String = "head"
def withJob(other: Job.Job): Piece = copy(job = other) override def withJob(other: Job): Piece = copy(job = other)
} }
case class Neck(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceAccessory { case class Body(override val pieceType: PieceType, override val job: Job) extends PieceBody {
val piece: String = "neck" override val piece: String = "body"
def withJob(other: Job.Job): Piece = copy(job = other) override def withJob(other: Job): Piece = copy(job = other)
} }
case class Wrist(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceAccessory { case class Hands(override val pieceType: PieceType, override val job: Job) extends PieceBody {
val piece: String = "wrist" override val piece: String = "hands"
def withJob(other: Job.Job): Piece = copy(job = other) override def withJob(other: Job): Piece = copy(job = other)
} }
case class Ring( case class Legs(override val pieceType: PieceType, override val job: Job) extends PieceBody {
override val pieceType: PieceType.PieceType, override val piece: String = "legs"
override val job: Job.Job, override def withJob(other: Job): Piece = copy(job = other)
}
case class Feet(override val pieceType: PieceType, override val job: Job) extends PieceBody {
override val piece: String = "feet"
override def withJob(other: Job): Piece = copy(job = other)
}
case class Ears(override val pieceType: PieceType, override val job: Job) extends PieceAccessory {
override val piece: String = "ears"
override def withJob(other: Job): Piece = copy(job = other)
}
case class Neck(override val pieceType: PieceType, override val job: Job) extends PieceAccessory {
override val piece: String = "neck"
override def withJob(other: Job): Piece = copy(job = other)
}
case class Wrist(override val pieceType: PieceType, override val job: Job) extends PieceAccessory {
override val piece: String = "wrist"
override def withJob(other: Job): Piece = copy(job = other)
}
case class Ring(
override val pieceType: PieceType,
override val job: Job,
override val piece: String = "ring" override val piece: String = "ring"
) extends PieceAccessory { ) extends PieceAccessory {
def withJob(other: Job.Job): Piece = copy(job = other) override def withJob(other: Job): Piece = copy(job = other)
override def equals(obj: Any): Boolean = obj match { override def equals(obj: Any): Boolean = obj match {
case Ring(thatPieceType, thatJob, _) => (thatPieceType == pieceType) && (thatJob == job) case Ring(thatPieceType, thatJob, _) => (thatPieceType == pieceType) && (thatJob == job)
@ -94,25 +96,24 @@ case class Ring(
case ring: Ring => equals(obj) && (ring.piece == this.piece) case ring: Ring => equals(obj) && (ring.piece == this.piece)
case _ => false case _ => false
} }
} }
case object AccessoryUpgrade extends PieceUpgrade { case object AccessoryUpgrade extends PieceUpgrade {
val piece: String = "accessory upgrade" override val piece: String = "accessory upgrade"
} }
case object BodyUpgrade extends PieceUpgrade { case object BodyUpgrade extends PieceUpgrade {
val piece: String = "body upgrade" override val piece: String = "body upgrade"
} }
case object WeaponUpgrade extends PieceUpgrade { case object WeaponUpgrade extends PieceUpgrade {
val piece: String = "weapon upgrade" override val piece: String = "weapon upgrade"
} }
object Piece { def apply(piece: String, pieceType: PieceType, job: Job = Job.AnyJob): Piece =
def apply(piece: String, pieceType: PieceType.PieceType, job: Job.Job = Job.AnyJob): Piece =
piece.toLowerCase match { piece.toLowerCase match {
case "weapon" => Weapon(pieceType, job) case "weapon" => Weapon(pieceType, job)
case "head" => Head(pieceType, job) case "head" => Head(pieceType, job)
case "body" => Body(pieceType, job) case "body" => Body(pieceType, job)
case "hands" => Hands(pieceType, job) case "hand" | "hands" => Hands(pieceType, job)
case "legs" => Legs(pieceType, job) case "legs" => Legs(pieceType, job)
case "feet" => Feet(pieceType, job) case "feet" => Feet(pieceType, job)
case "ears" => Ears(pieceType, job) case "ears" => Ears(pieceType, job)
@ -125,7 +126,7 @@ object Piece {
case other => throw new Error(s"Unknown item type $other") case other => throw new Error(s"Unknown item type $other")
} }
lazy val available: Seq[String] = Seq( val available: Seq[String] = Seq(
"weapon", "weapon",
"head", "head",
"body", "body",

View File

@ -8,17 +8,16 @@
*/ */
package me.arcanis.ffxivbis.models package me.arcanis.ffxivbis.models
sealed trait PieceType
object PieceType { object PieceType {
sealed trait PieceType
case object Crafted extends PieceType
case object Tome extends PieceType
case object Savage extends PieceType case object Savage extends PieceType
case object Tome extends PieceType
case object Crafted extends PieceType
case object Artifact extends PieceType case object Artifact extends PieceType
lazy val available: Seq[PieceType] = val available: Seq[PieceType] = Seq(Savage, Tome, Crafted, Artifact)
Seq(Crafted, Tome, Savage, Artifact)
def withName(pieceType: String): PieceType = def withName(pieceType: String): PieceType =
available.find(_.toString.equalsIgnoreCase(pieceType)) match { available.find(_.toString.equalsIgnoreCase(pieceType)) match {

View File

@ -11,7 +11,7 @@ package me.arcanis.ffxivbis.models
case class Player( case class Player(
id: Long, id: Long,
partyId: String, partyId: String,
job: Job.Job, job: Job,
nick: String, nick: String,
bis: BiS, bis: BiS,
loot: Seq[Loot], loot: Seq[Loot],
@ -51,7 +51,7 @@ case class Player(
piece match { piece match {
case None => false case None => false
case Some(p) if !bis.hasPiece(p) => false case Some(p) if !bis.hasPiece(p) => false
case Some(p: PieceUpgrade) => bis.upgrades(p) > lootCount(piece) case Some(p: Piece.PieceUpgrade) => bis.upgrades(p) > lootCount(piece)
case Some(_) => lootCount(piece) == 0 case Some(_) => lootCount(piece) == 0
} }

View File

@ -13,14 +13,14 @@ import scala.util.matching.Regex
trait PlayerIdBase { trait PlayerIdBase {
def job: Job.Job def job: Job
def nick: String def nick: String
override def toString: String = s"$nick ($job)" override def toString: String = s"$nick ($job)"
} }
case class PlayerId(partyId: String, job: Job.Job, nick: String) extends PlayerIdBase case class PlayerId(partyId: String, job: Job, nick: String) extends PlayerIdBase
object PlayerId { object PlayerId {

View File

@ -10,7 +10,7 @@ package me.arcanis.ffxivbis.models
case class PlayerIdWithCounters( case class PlayerIdWithCounters(
partyId: String, partyId: String,
job: Job.Job, job: Job,
nick: String, nick: String,
isRequired: Boolean, isRequired: Boolean,
priority: Int, priority: Int,
@ -24,8 +24,6 @@ case class PlayerIdWithCounters(
def gt(that: PlayerIdWithCounters, orderBy: Seq[String]): Boolean = def gt(that: PlayerIdWithCounters, orderBy: Seq[String]): Boolean =
withCounters(orderBy) > that.withCounters(orderBy) withCounters(orderBy) > that.withCounters(orderBy)
def isRequiredToString: String = if (isRequired) "yes" else "no"
def playerId: PlayerId = PlayerId(partyId, job, nick) def playerId: PlayerId = PlayerId(partyId, job, nick)
private val counters: Map[String, Int] = Map( private val counters: Map[String, Int] = Map(
@ -47,13 +45,13 @@ object PlayerIdWithCounters {
def >(that: PlayerCountersComparator): Boolean = { def >(that: PlayerCountersComparator): Boolean = {
@scala.annotation.tailrec @scala.annotation.tailrec
def compareLists(left: List[Int], right: List[Int]): Boolean = def compare(left: Seq[Int], right: Seq[Int]): Boolean =
(left, right) match { (left, right) match {
case (hl :: tl, hr :: tr) => if (hl == hr) compareLists(tl, tr) else hl > hr case (hl :: tl, hr :: tr) => if (hl == hr) compare(tl, tr) else hl > hr
case (_ :: _, Nil) => true case (_ :: _, Nil) => true
case (_, _) => false case (_, _) => false
} }
compareLists(values.toList, that.values.toList) compare(values, that.values)
} }
} }
} }

View File

@ -11,6 +11,7 @@ package me.arcanis.ffxivbis.models
import org.mindrot.jbcrypt.BCrypt import org.mindrot.jbcrypt.BCrypt
object Permission extends Enumeration { object Permission extends Enumeration {
val get, post, admin = Value val get, post, admin = Value
} }

View File

@ -16,7 +16,6 @@ import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.messages._ import me.arcanis.ffxivbis.messages._
import me.arcanis.ffxivbis.models.Party import me.arcanis.ffxivbis.models.Party
import scala.concurrent.duration.FiniteDuration
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
class PartyService(context: ActorContext[Message], storage: ActorRef[DatabaseMessage]) class PartyService(context: ActorContext[Message], storage: ActorRef[DatabaseMessage])
@ -24,36 +23,37 @@ class PartyService(context: ActorContext[Message], storage: ActorRef[DatabaseMes
with StrictLogging { with StrictLogging {
import me.arcanis.ffxivbis.utils.Implicits._ import me.arcanis.ffxivbis.utils.Implicits._
private val cacheTimeout: FiniteDuration = private val cacheTimeout =
context.system.settings.config.getDuration("me.arcanis.ffxivbis.settings.cache-timeout") context.system.settings.config.getFiniteDuration("me.arcanis.ffxivbis.settings.cache-timeout")
implicit private val executionContext: ExecutionContext = { implicit private val executionContext: ExecutionContext = {
val selector = DispatcherSelector.fromConfig("me.arcanis.ffxivbis.default-dispatcher") val selector = DispatcherSelector.fromConfig("me.arcanis.ffxivbis.default-dispatcher")
context.system.dispatchers.lookup(selector) context.system.dispatchers.lookup(selector)
} }
implicit private val timeout: Timeout = implicit private val timeout: Timeout =
context.system.settings.config.getDuration("me.arcanis.ffxivbis.settings.request-timeout") context.system.settings.config.getTimeout("me.arcanis.ffxivbis.settings.request-timeout")
implicit private val scheduler: Scheduler = context.system.scheduler implicit private val scheduler: Scheduler = context.system.scheduler
override def onMessage(msg: Message): Behavior[Message] = handle(Map.empty)(msg) override def onMessage(msg: Message): Behavior[Message] = handle(Map.empty)(msg)
private def handle(cache: Map[String, Party]): Message.Handler = { private def handle(cache: Map[String, Party]): Message.Handler = {
case ForgetParty(partyId) => case ControlMessage.ForgetParty(partyId) =>
Behaviors.receiveMessage(handle(cache - partyId)) Behaviors.receiveMessage(handle(cache - partyId))
case GetNewPartyId(client) => case ControlMessage.GetNewPartyId(client) =>
getPartyId.foreach(client ! _) getPartyId.foreach(client ! _)
Behaviors.same Behaviors.same
case StoreParty(partyId, party) => case ControlMessage.StoreParty(partyId, party) =>
Behaviors.receiveMessage(handle(cache.updated(partyId, party))) Behaviors.receiveMessage(handle(cache.updated(partyId, party)))
case GetParty(partyId, client) => case DatabaseMessage.GetParty(partyId, client) =>
val party = cache.get(partyId) match { val party = cache.get(partyId) match {
case Some(party) => Future.successful(party) case Some(party) => Future.successful(party)
case None => case None =>
storage.ask(ref => GetParty(partyId, ref)).map { party => storage.ask(ref => DatabaseMessage.GetParty(partyId, ref)).map { party =>
context.self ! StoreParty(partyId, party) context.self ! ControlMessage.StoreParty(partyId, party)
context.system.scheduler.scheduleOnce(cacheTimeout, () => context.self ! ForgetParty(partyId)) context.system.scheduler
.scheduleOnce(cacheTimeout, () => context.self ! ControlMessage.ForgetParty(partyId))
party party
} }
} }
@ -62,12 +62,13 @@ class PartyService(context: ActorContext[Message], storage: ActorRef[DatabaseMes
case req: DatabaseMessage => case req: DatabaseMessage =>
storage ! req storage ! req
Behaviors.receiveMessage(handle(cache - req.partyId)) val result = if (req.isReadOnly) cache else cache - req.partyId
Behaviors.receiveMessage(handle(result))
} }
private def getPartyId: Future[String] = { private def getPartyId: Future[String] = {
val partyId = Party.randomPartyId val partyId = Party.randomPartyId
storage.ask(ref => Exists(partyId, ref)).flatMap { storage.ask(ref => DatabaseMessage.Exists(partyId, ref)).flatMap {
case true => getPartyId case true => getPartyId
case false => Future.successful(partyId) case false => Future.successful(partyId)
} }

View File

@ -13,14 +13,15 @@ import akka.actor.typed.scaladsl.{AbstractBehavior, ActorContext, Behaviors}
import akka.actor.typed.{Behavior, PostStop, Signal} import akka.actor.typed.{Behavior, PostStop, Signal}
import akka.http.scaladsl.model._ import akka.http.scaladsl.model._
import com.typesafe.scalalogging.StrictLogging import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, DownloadBiS} import me.arcanis.ffxivbis.messages.BiSProviderMessage
import me.arcanis.ffxivbis.models.{BiS, Job, Piece, PieceType} import me.arcanis.ffxivbis.models.{BiS, Job, Piece, PieceType}
import me.arcanis.ffxivbis.service.bis.parser.Parser import me.arcanis.ffxivbis.service.bis.parser.Parser
import me.arcanis.ffxivbis.service.bis.parser.impl.{Ariyala, Etro} import me.arcanis.ffxivbis.service.bis.parser.impl.{Ariyala, Etro, XIVGear}
import spray.json._ import spray.json._
import java.nio.file.Paths import java.nio.file.Paths
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import scala.util.control.NonFatal
import scala.util.{Failure, Success} import scala.util.{Failure, Success}
class BisProvider(context: ActorContext[BiSProviderMessage]) class BisProvider(context: ActorContext[BiSProviderMessage])
@ -32,7 +33,7 @@ class BisProvider(context: ActorContext[BiSProviderMessage])
override def onMessage(msg: BiSProviderMessage): Behavior[BiSProviderMessage] = override def onMessage(msg: BiSProviderMessage): Behavior[BiSProviderMessage] =
msg match { msg match {
case DownloadBiS(link, job, client) => case BiSProviderMessage.DownloadBiS(link, job, client) =>
get(link, job).onComplete { get(link, job).onComplete {
case Success(items) => client ! BiS(items) case Success(items) => client ! BiS(items)
case Failure(exception) => case Failure(exception) =>
@ -46,16 +47,19 @@ class BisProvider(context: ActorContext[BiSProviderMessage])
Behaviors.same Behaviors.same
} }
private def get(link: String, job: Job.Job): Future[Seq[Piece]] = private def get(link: String, job: Job): Future[Seq[Piece]] =
try { try {
val url = Uri(link) val url = Uri(link)
val id = Paths.get(link).normalize.getFileName.toString val id = Paths.get(link).normalize.getFileName.toString
val parser = if (url.authority.host.address().contains("etro")) Etro else Ariyala val parser =
if (url.authority.host.address().contains("etro")) Etro
else if (url.authority.host.address().contains("xivgear.app")) XIVGear
else Ariyala
val uri = parser.uri(url, id) val uri = parser.uri(url, id)
sendRequest(uri, BisProvider.parseBisJsonToPieces(job, parser, getPieceType)) sendRequest(uri, BisProvider.parseBisJsonToPieces(job, parser, getPieceType))
} catch { } catch {
case exception: Exception => Future.failed(exception) case NonFatal(exception) => Future.failed(exception)
} }
} }
@ -65,9 +69,9 @@ object BisProvider {
Behaviors.setup[BiSProviderMessage](context => new BisProvider(context)) Behaviors.setup[BiSProviderMessage](context => new BisProvider(context))
private def parseBisJsonToPieces( private def parseBisJsonToPieces(
job: Job.Job, job: Job,
idParser: Parser, idParser: Parser,
pieceTypes: Seq[Long] => Future[Map[Long, PieceType.PieceType]] pieceTypes: Seq[Long] => Future[Map[Long, PieceType]]
)(js: JsObject)(implicit executionContext: ExecutionContext): Future[Seq[Piece]] = )(js: JsObject)(implicit executionContext: ExecutionContext): Future[Seq[Piece]] =
idParser.parse(job, js).flatMap { pieces => idParser.parse(job, js).flatMap { pieces =>
pieceTypes(pieces.values.toSeq).map { types => pieceTypes(pieces.values.toSeq).map { types =>
@ -80,12 +84,11 @@ object BisProvider {
} }
} }
def remapKey(key: String): Option[String] = key match { def remapKey(key: String): Option[String] = Some(key.toLowerCase).collect {
case "mainhand" => Some("weapon") case "mainhand" => "weapon"
case "chest" => Some("body") case "chest" => "body"
case "ringLeft" | "fingerL" => Some("left ring") case "ringleft" | "fingerl" => "left ring"
case "ringRight" | "fingerR" => Some("right ring") case "ringright" | "fingerr" => "right ring"
case "weapon" | "head" | "body" | "hands" | "legs" | "feet" | "ears" | "neck" | "wrist" | "wrists" => Some(key) case "weapon" | "head" | "body" | "hand" | "hands" | "legs" | "feet" | "ears" | "neck" | "wrist" | "wrists" => key
case _ => None
} }
} }

View File

@ -15,6 +15,7 @@ import spray.json._
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import scala.jdk.CollectionConverters._ import scala.jdk.CollectionConverters._
import scala.util.Try import scala.util.Try
import scala.util.matching.Regex
trait XivApi extends RequestExecutor { trait XivApi extends RequestExecutor {
@ -22,7 +23,7 @@ trait XivApi extends RequestExecutor {
private val xivapiUrl = config.getString("me.arcanis.ffxivbis.bis-provider.xivapi-url") private val xivapiUrl = config.getString("me.arcanis.ffxivbis.bis-provider.xivapi-url")
private val xivapiKey = Try(config.getString("me.arcanis.ffxivbis.bis-provider.xivapi-key")).toOption private val xivapiKey = Try(config.getString("me.arcanis.ffxivbis.bis-provider.xivapi-key")).toOption
private val preloadedItems: Map[Long, PieceType.PieceType] = private val preloadedItems: Map[Long, PieceType] =
config config
.getConfigList("me.arcanis.ffxivbis.bis-provider.cached-items") .getConfigList("me.arcanis.ffxivbis.bis-provider.cached-items")
.asScala .asScala
@ -31,9 +32,8 @@ trait XivApi extends RequestExecutor {
} }
.toMap .toMap
def getPieceType(itemIds: Seq[Long]): Future[Map[Long, PieceType.PieceType]] = { def getPieceType(itemIds: Seq[Long]): Future[Map[Long, PieceType]] = {
val (local, remote) = itemIds.foldLeft((Map.empty[Long, PieceType.PieceType], Seq.empty[Long])) { val (local, remote) = itemIds.foldLeft((Map.empty[Long, PieceType], Seq.empty[Long])) { case ((l, r), id) =>
case ((l, r), id) =>
if (preloadedItems.contains(id)) (l.updated(id, preloadedItems(id)), r) if (preloadedItems.contains(id)) (l.updated(id, preloadedItems(id)), r)
else (l, r :+ id) else (l, r :+ id)
} }
@ -41,7 +41,7 @@ trait XivApi extends RequestExecutor {
else remotePieceType(remote).map(_ ++ local) else remotePieceType(remote).map(_ ++ local)
} }
private def remotePieceType(itemIds: Seq[Long]): Future[Map[Long, PieceType.PieceType]] = { private def remotePieceType(itemIds: Seq[Long]): Future[Map[Long, PieceType]] = {
val uriForItems = Uri(xivapiUrl) val uriForItems = Uri(xivapiUrl)
.withPath(Uri.Path / "item") .withPath(Uri.Path / "item")
.withQuery( .withQuery(
@ -76,6 +76,10 @@ trait XivApi extends RequestExecutor {
object XivApi { object XivApi {
private val defaultShop = JsObject("IsUnique" -> JsNumber(1), "StackSize" -> JsNumber(999))
private val itemRegexp = new Regex("""Item(Receive|Cost)(\d+)""", "type", "index")
private def parseXivapiJsonToShop( private def parseXivapiJsonToShop(
js: JsObject js: JsObject
)(implicit executionContext: ExecutionContext): Future[Map[Long, (String, Long)]] = { )(implicit executionContext: ExecutionContext): Future[Map[Long, (String, Long)]] = {
@ -85,12 +89,12 @@ object XivApi {
.map(_ => "crafted" -> -1L) // you can craft this item .map(_ => "crafted" -> -1L) // you can craft this item
.orElse { // lets try shop items .orElse { // lets try shop items
js.fields("SpecialShop").asJsObject.fields.collectFirst { js.fields("SpecialShop").asJsObject.fields.collectFirst {
case (shopName, JsArray(array)) if shopName.startsWith("ItemReceive") => case (shopName, JsArray(array)) if itemRegexp.matches(shopName) =>
val shopId = array.head match { val shopId = array.head match {
case JsNumber(id) => id.toLong case JsNumber(id) => id.toLong
case other => throw deserializationError(s"Could not parse $other") case other => throw deserializationError(s"Could not parse $other")
} }
shopName.replace("ItemReceive", "") -> shopId itemRegexp.findFirstMatchIn(shopName).get.group("index") -> shopId
} }
} }
.getOrElse(throw deserializationError(s"Could not parse $js")) .getOrElse(throw deserializationError(s"Could not parse $js"))
@ -111,7 +115,7 @@ object XivApi {
private def parseXivapiJsonToType( private def parseXivapiJsonToType(
shops: Map[Long, (String, Long)] shops: Map[Long, (String, Long)]
)(js: JsObject)(implicit executionContext: ExecutionContext): Future[Map[Long, PieceType.PieceType]] = )(js: JsObject)(implicit executionContext: ExecutionContext): Future[Map[Long, PieceType]] =
Future { Future {
val shopMap = js.fields("Results") match { val shopMap = js.fields("Results") match {
case array: JsArray => case array: JsArray =>
@ -129,7 +133,7 @@ object XivApi {
if (index == "crafted" && shopId == -1L) PieceType.Crafted if (index == "crafted" && shopId == -1L) PieceType.Crafted
else else
Try(shopMap(shopId).fields(s"ItemCost$index").asJsObject) Try(shopMap(shopId).fields(s"ItemCost$index").asJsObject)
.getOrElse(throw new Exception(s"${shopMap(shopId).fields(s"ItemCost$index")}, $index")) .getOrElse(defaultShop)
.getFields("IsUnique", "StackSize") match { .getFields("IsUnique", "StackSize") match {
case Seq(JsNumber(isUnique), JsNumber(stackSize)) => case Seq(JsNumber(isUnique), JsNumber(stackSize)) =>
if (isUnique == 1 || stackSize.toLong != 999) PieceType.Tome // either upgraded gear or tomes found if (isUnique == 1 || stackSize.toLong != 999) PieceType.Tome // either upgraded gear or tomes found

View File

@ -17,7 +17,7 @@ import scala.concurrent.{ExecutionContext, Future}
trait Parser extends StrictLogging { trait Parser extends StrictLogging {
def parse(job: Job.Job, js: JsObject)(implicit executionContext: ExecutionContext): Future[Map[String, Long]] def parse(job: Job, js: JsObject)(implicit executionContext: ExecutionContext): Future[Map[String, Long]]
def uri(root: Uri, id: String): Uri def uri(root: Uri, id: String): Uri
} }

View File

@ -18,7 +18,7 @@ import scala.concurrent.{ExecutionContext, Future}
object Ariyala extends Parser { object Ariyala extends Parser {
override def parse(job: Job.Job, js: JsObject)(implicit override def parse(job: Job, js: JsObject)(implicit
executionContext: ExecutionContext executionContext: ExecutionContext
): Future[Map[String, Long]] = ): Future[Map[String, Long]] =
Future { Future {

View File

@ -18,7 +18,7 @@ import scala.concurrent.{ExecutionContext, Future}
object Etro extends Parser { object Etro extends Parser {
override def parse(job: Job.Job, js: JsObject)(implicit override def parse(job: Job, js: JsObject)(implicit
executionContext: ExecutionContext executionContext: ExecutionContext
): Future[Map[String, Long]] = ): Future[Map[String, Long]] =
Future { Future {

View File

@ -0,0 +1,44 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.service.bis.parser.impl
import akka.http.scaladsl.model.Uri
import me.arcanis.ffxivbis.models.Job
import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.bis.parser.Parser
import spray.json.{deserializationError, JsNumber, JsObject}
import scala.concurrent.{ExecutionContext, Future}
object XIVGear extends Parser {
override def parse(job: Job, js: JsObject)(implicit executionContext: ExecutionContext): Future[Map[String, Long]] =
Future {
val set = js.fields.get("items") match {
case Some(JsObject(items)) => items
case other => throw deserializationError(s"Invalid job name $other")
}
set.foldLeft(Map.empty[String, Long]) {
case (acc, (key, JsObject(properties))) =>
val pieceId = properties.get("id").collect { case JsNumber(id) =>
id.toLong
}
(for (
piece <- BisProvider.remapKey(key);
id <- pieceId
) yield (piece, id)).map(acc + _).getOrElse(acc)
case (acc, _) => acc
}
}
override def uri(root: Uri, id: String): Uri = {
val gearSet = Uri(id).query().get("page").map(_.replace("sl|", "")).getOrElse(id)
root.withHost(s"api.${root.authority.host.address()}").withPath(Uri.Path / "shortlink" / gearSet)
}
}

View File

@ -18,11 +18,14 @@ import me.arcanis.ffxivbis.service.database.impl.DatabaseImpl
import me.arcanis.ffxivbis.storage.DatabaseProfile import me.arcanis.ffxivbis.storage.DatabaseProfile
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success}
trait Database extends StrictLogging { trait Database extends StrictLogging {
implicit def executionContext: ExecutionContext implicit def executionContext: ExecutionContext
def config: Config def config: Config
def profile: DatabaseProfile def profile: DatabaseProfile
def filterParty(party: Party, maybePlayerId: Option[PlayerId]): Seq[Player] = def filterParty(party: Party, maybePlayerId: Option[PlayerId]): Seq[Player] =
@ -35,9 +38,15 @@ trait Database extends StrictLogging {
for { for {
partyDescription <- profile.getPartyDescription(partyId) partyDescription <- profile.getPartyDescription(partyId)
players <- profile.getParty(partyId) players <- profile.getParty(partyId)
bis <- if (withBiS) profile.getPiecesBiS(partyId) else Future(Seq.empty) bis <- if (withBiS) profile.getPiecesBiS(partyId) else Future.successful(Seq.empty)
loot <- if (withLoot) profile.getPieces(partyId) else Future(Seq.empty) loot <- if (withLoot) profile.getPieces(partyId) else Future.successful(Seq.empty)
} yield Party(partyDescription, config, players, bis, loot) } yield Party(partyDescription, config, players, bis, loot)
protected def run[T](fn: => Future[T])(onSuccess: T => Unit): Unit =
fn.onComplete {
case Success(value) => onSuccess(value)
case Failure(exception) => logger.error("exception during performing database request", exception)
}
} }
object Database { object Database {

View File

@ -6,9 +6,10 @@
* *
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause * License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/ */
package me.arcanis.ffxivbis.storage package me.arcanis.ffxivbis.service.database
import com.typesafe.config.Config import com.typesafe.config.Config
import me.arcanis.ffxivbis.storage.DatabaseProfile
import org.flywaydb.core.Flyway import org.flywaydb.core.Flyway
import org.flywaydb.core.api.configuration.ClassicConfiguration import org.flywaydb.core.api.configuration.ClassicConfiguration
import org.flywaydb.core.api.output.MigrateResult import org.flywaydb.core.api.output.MigrateResult
@ -17,12 +18,14 @@ import scala.util.Try
class Migration(config: Config) { class Migration(config: Config) {
import me.arcanis.ffxivbis.utils.Implicits._
def performMigration(): Try[MigrateResult] = { def performMigration(): Try[MigrateResult] = {
val section = DatabaseProfile.getSection(config) val section = DatabaseProfile.getSection(config)
val url = section.getString("db.url") val url = section.getString("jdbcUrl")
val username = Try(section.getString("db.user")).toOption.filter(_.nonEmpty).orNull val username = section.getOptString("username").orNull
val password = Try(section.getString("db.password")).toOption.filter(_.nonEmpty).orNull val password = section.getOptString("password").orNull
val provider = url match { val provider = url match {
case s"jdbc:$p:$_" => p case s"jdbc:$p:$_" => p

View File

@ -8,29 +8,33 @@
*/ */
package me.arcanis.ffxivbis.service.database.impl package me.arcanis.ffxivbis.service.database.impl
import akka.actor.typed.Behavior
import akka.actor.typed.scaladsl.Behaviors import akka.actor.typed.scaladsl.Behaviors
import me.arcanis.ffxivbis.messages._ import me.arcanis.ffxivbis.messages.DatabaseMessage
import me.arcanis.ffxivbis.messages.DatabaseMessage._
import me.arcanis.ffxivbis.service.database.Database import me.arcanis.ffxivbis.service.database.Database
trait DatabaseBiSHandler { this: Database => trait DatabaseBiSHandler { this: Database =>
def bisHandler: DatabaseMessage.Handler = { def bisHandler(msg: BisDatabaseMessage): Behavior[DatabaseMessage] =
msg match {
case AddPieceToBis(playerId, piece, client) => case AddPieceToBis(playerId, piece, client) =>
profile.insertPieceBiS(playerId, piece).foreach(_ => client ! ()) run(profile.insertPieceBiS(playerId, piece))(_ => client ! ())
Behaviors.same Behaviors.same
case GetBiS(partyId, maybePlayerId, client) => case GetBiS(partyId, maybePlayerId, client) =>
run {
getParty(partyId, withBiS = true, withLoot = false) getParty(partyId, withBiS = true, withLoot = false)
.map(filterParty(_, maybePlayerId)) .map(filterParty(_, maybePlayerId))
.foreach(client ! _) }(client ! _)
Behaviors.same Behaviors.same
case RemovePieceFromBiS(playerId, piece, client) => case RemovePieceFromBiS(playerId, piece, client) =>
profile.deletePieceBiS(playerId, piece).foreach(_ => client ! ()) run(profile.deletePieceBiS(playerId, piece))(_ => client ! ())
Behaviors.same Behaviors.same
case RemovePiecesFromBiS(playerId, client) => case RemovePiecesFromBiS(playerId, client) =>
profile.deletePiecesBiS(playerId).foreach(_ => client ! ()) run(profile.deletePiecesBiS(playerId))(_ => client ! ())
Behaviors.same Behaviors.same
} }
} }

View File

@ -12,6 +12,7 @@ import akka.actor.typed.scaladsl.{AbstractBehavior, ActorContext}
import akka.actor.typed.{Behavior, DispatcherSelector} import akka.actor.typed.{Behavior, DispatcherSelector}
import com.typesafe.config.Config import com.typesafe.config.Config
import me.arcanis.ffxivbis.messages.DatabaseMessage import me.arcanis.ffxivbis.messages.DatabaseMessage
import me.arcanis.ffxivbis.messages.DatabaseMessage.{BisDatabaseMessage, LootDatabaseMessage, PartyDatabaseMessage, UserDatabaseMessage}
import me.arcanis.ffxivbis.service.database.Database import me.arcanis.ffxivbis.service.database.Database
import me.arcanis.ffxivbis.storage.DatabaseProfile import me.arcanis.ffxivbis.storage.DatabaseProfile
@ -32,8 +33,12 @@ class DatabaseImpl(context: ActorContext[DatabaseMessage])
override val config: Config = context.system.settings.config override val config: Config = context.system.settings.config
override val profile: DatabaseProfile = new DatabaseProfile(executionContext, config) override val profile: DatabaseProfile = new DatabaseProfile(executionContext, config)
override def onMessage(msg: DatabaseMessage): Behavior[DatabaseMessage] = handle(msg) override def onMessage(msg: DatabaseMessage): Behavior[DatabaseMessage] =
msg match {
case msg: BisDatabaseMessage => bisHandler(msg)
case msg: LootDatabaseMessage => lootHandler(msg)
case msg: PartyDatabaseMessage => partyHandler(msg)
case msg: UserDatabaseMessage => userHandler(msg)
}
private def handle: DatabaseMessage.Handler =
bisHandler.orElse(lootHandler).orElse(partyHandler).orElse(userHandler)
} }

View File

@ -8,8 +8,10 @@
*/ */
package me.arcanis.ffxivbis.service.database.impl package me.arcanis.ffxivbis.service.database.impl
import akka.actor.typed.Behavior
import akka.actor.typed.scaladsl.Behaviors import akka.actor.typed.scaladsl.Behaviors
import me.arcanis.ffxivbis.messages._ import me.arcanis.ffxivbis.messages.DatabaseMessage
import me.arcanis.ffxivbis.messages.DatabaseMessage._
import me.arcanis.ffxivbis.models.Loot import me.arcanis.ffxivbis.models.Loot
import me.arcanis.ffxivbis.service.database.Database import me.arcanis.ffxivbis.service.database.Database
@ -17,26 +19,29 @@ import java.time.Instant
trait DatabaseLootHandler { this: Database => trait DatabaseLootHandler { this: Database =>
def lootHandler: DatabaseMessage.Handler = { def lootHandler(msg: LootDatabaseMessage): Behavior[DatabaseMessage] =
msg match {
case AddPieceTo(playerId, piece, isFreeLoot, client) => case AddPieceTo(playerId, piece, isFreeLoot, client) =>
val loot = Loot(-1, piece, Instant.now, isFreeLoot) val loot = Loot(-1, piece, Instant.now, isFreeLoot)
profile.insertPiece(playerId, loot).foreach(_ => client ! ()) run(profile.insertPiece(playerId, loot))(_ => client ! ())
Behaviors.same Behaviors.same
case GetLoot(partyId, maybePlayerId, client) => case GetLoot(partyId, maybePlayerId, client) =>
run {
getParty(partyId, withBiS = false, withLoot = true) getParty(partyId, withBiS = false, withLoot = true)
.map(filterParty(_, maybePlayerId)) .map(filterParty(_, maybePlayerId))
.foreach(client ! _) }(client ! _)
Behaviors.same Behaviors.same
case RemovePieceFrom(playerId, piece, client) => case RemovePieceFrom(playerId, piece, isFreeLoot, client) =>
profile.deletePiece(playerId, piece).foreach(_ => client ! ()) run(profile.deletePiece(playerId, piece, isFreeLoot))(_ => client ! ())
Behaviors.same Behaviors.same
case SuggestLoot(partyId, piece, client) => case SuggestLoot(partyId, piece, client) =>
run {
getParty(partyId, withBiS = true, withLoot = true) getParty(partyId, withBiS = true, withLoot = true)
.map(_.suggestLoot(piece)) .map(_.suggestLoot(piece))
.foreach(client ! _) }(client ! _)
Behaviors.same Behaviors.same
} }
} }

View File

@ -8,8 +8,10 @@
*/ */
package me.arcanis.ffxivbis.service.database.impl package me.arcanis.ffxivbis.service.database.impl
import akka.actor.typed.Behavior
import akka.actor.typed.scaladsl.Behaviors import akka.actor.typed.scaladsl.Behaviors
import me.arcanis.ffxivbis.messages._ import me.arcanis.ffxivbis.messages.DatabaseMessage
import me.arcanis.ffxivbis.messages.DatabaseMessage._
import me.arcanis.ffxivbis.models.{BiS, Player} import me.arcanis.ffxivbis.models.{BiS, Player}
import me.arcanis.ffxivbis.service.database.Database import me.arcanis.ffxivbis.service.database.Database
@ -17,21 +19,23 @@ import scala.concurrent.Future
trait DatabasePartyHandler { this: Database => trait DatabasePartyHandler { this: Database =>
def partyHandler: DatabaseMessage.Handler = { def partyHandler(msg: PartyDatabaseMessage): Behavior[DatabaseMessage] =
msg match {
case AddPlayer(player, client) => case AddPlayer(player, client) =>
profile.insertPlayer(player).foreach(_ => client ! ()) run(profile.insertPlayer(player))(_ => client ! ())
Behaviors.same Behaviors.same
case GetParty(partyId, client) => case GetParty(partyId, client) =>
getParty(partyId, withBiS = true, withLoot = true).foreach(client ! _) run(getParty(partyId, withBiS = true, withLoot = true))(client ! _)
Behaviors.same Behaviors.same
case GetPartyDescription(partyId, client) => case GetPartyDescription(partyId, client) =>
profile.getPartyDescription(partyId).foreach(client ! _) run(profile.getPartyDescription(partyId))(client ! _)
Behaviors.same Behaviors.same
case GetPlayer(playerId, client) => case GetPlayer(playerId, client) =>
val player = profile run {
profile
.getPlayerFull(playerId) .getPlayerFull(playerId)
.flatMap { maybePlayerData => .flatMap { maybePlayerData =>
Future.traverse(maybePlayerData.toSeq) { playerData => Future.traverse(maybePlayerData.toSeq) { playerData =>
@ -51,15 +55,19 @@ trait DatabasePartyHandler { this: Database =>
} }
} }
.map(_.headOption) .map(_.headOption)
player.foreach(client ! _) }(client ! _)
Behaviors.same Behaviors.same
case RemovePlayer(playerId, client) => case RemovePlayer(playerId, client) =>
profile.deletePlayer(playerId).foreach(_ => client ! ()) run(profile.deletePlayer(playerId))(_ => client ! ())
Behaviors.same
case UpdateBiSLink(playerId, link, client) =>
run(profile.updateBiSLink(playerId, link))(_ => client ! ())
Behaviors.same Behaviors.same
case UpdateParty(description, client) => case UpdateParty(description, client) =>
profile.insertPartyDescription(description).foreach(_ => client ! ()) run(profile.insertPartyDescription(description))(_ => client ! ())
Behaviors.same Behaviors.same
} }
} }

View File

@ -8,32 +8,35 @@
*/ */
package me.arcanis.ffxivbis.service.database.impl package me.arcanis.ffxivbis.service.database.impl
import akka.actor.typed.Behavior
import akka.actor.typed.scaladsl.Behaviors import akka.actor.typed.scaladsl.Behaviors
import me.arcanis.ffxivbis.messages._ import me.arcanis.ffxivbis.messages.DatabaseMessage
import me.arcanis.ffxivbis.messages.DatabaseMessage._
import me.arcanis.ffxivbis.service.database.Database import me.arcanis.ffxivbis.service.database.Database
trait DatabaseUserHandler { this: Database => trait DatabaseUserHandler { this: Database =>
def userHandler: DatabaseMessage.Handler = { def userHandler(msg: UserDatabaseMessage): Behavior[DatabaseMessage] =
msg match {
case AddUser(user, isHashedPassword, client) => case AddUser(user, isHashedPassword, client) =>
val toInsert = if (isHashedPassword) user else user.withHashedPassword val toInsert = if (isHashedPassword) user else user.withHashedPassword
profile.insertUser(toInsert).foreach(_ => client ! ()) run(profile.insertUser(toInsert))(_ => client ! ())
Behaviors.same Behaviors.same
case DeleteUser(partyId, username, client) => case DeleteUser(partyId, username, client) =>
profile.deleteUser(partyId, username).foreach(_ => client ! ()) run(profile.deleteUser(partyId, username))(_ => client ! ())
Behaviors.same Behaviors.same
case Exists(partyId, client) => case Exists(partyId, client) =>
profile.exists(partyId).foreach(client ! _) run(profile.exists(partyId))(client ! _)
Behaviors.same Behaviors.same
case GetUser(partyId, username, client) => case GetUser(partyId, username, client) =>
profile.getUser(partyId, username).foreach(client ! _) run(profile.getUser(partyId, username))(client ! _)
Behaviors.same Behaviors.same
case GetUsers(partyId, client) => case GetUsers(partyId, client) =>
profile.getUsers(partyId).foreach(client ! _) run(profile.getUsers(partyId))(client ! _)
Behaviors.same Behaviors.same
} }
} }

View File

@ -8,66 +8,74 @@
*/ */
package me.arcanis.ffxivbis.storage package me.arcanis.ffxivbis.storage
import anorm.SqlParser._
import anorm._
import me.arcanis.ffxivbis.models.{Job, Loot, Piece, PieceType} import me.arcanis.ffxivbis.models.{Job, Loot, Piece, PieceType}
import slick.lifted.ForeignKeyQuery
import java.time.Instant import java.time.Instant
import scala.concurrent.Future import scala.concurrent.Future
trait BiSProfile { this: DatabaseProfile => trait BiSProfile extends DatabaseConnection {
import dbConfig.profile.api._
case class BiSRep(playerId: Long, created: Long, piece: String, pieceType: String, job: String) { private val loot: RowParser[Loot] =
(long("player_id") ~ str("piece") ~ str("piece_type")
def toLoot: Loot = Loot( ~ str("job") ~ long("created"))
playerId, .map { case playerId ~ piece ~ pieceType ~ job ~ created =>
Piece(piece, PieceType.withName(pieceType), Job.withName(job)), Loot(
Instant.ofEpochMilli(created), playerId = playerId,
isFreeLoot = false piece = Piece(
piece = piece,
pieceType = PieceType.withName(pieceType),
job = Job.withName(job)
),
timestamp = Instant.ofEpochMilli(created),
isFreeLoot = false,
) )
} }
object BiSRep {
def fromPiece(playerId: Long, piece: Piece): BiSRep =
BiSRep(playerId, DatabaseProfile.now, piece.piece, piece.pieceType.toString, piece.job.toString)
}
class BiSPieces(tag: Tag) extends Table[BiSRep](tag, "bis") {
def playerId: Rep[Long] = column[Long]("player_id", O.PrimaryKey)
def created: Rep[Long] = column[Long]("created")
def piece: Rep[String] = column[String]("piece", O.PrimaryKey)
def pieceType: Rep[String] = column[String]("piece_type")
def job: Rep[String] = column[String]("job")
def * =
(playerId, created, piece, pieceType, job) <> ((BiSRep.apply _).tupled, BiSRep.unapply)
def fkPlayerId: ForeignKeyQuery[Players, PlayerRep] =
foreignKey("player_id", playerId, playersTable)(_.playerId, onDelete = ForeignKeyAction.Cascade)
}
def deletePieceBiSById(piece: Piece)(playerId: Long): Future[Int] = def deletePieceBiSById(piece: Piece)(playerId: Long): Future[Int] =
db.run(pieceBiS(BiSRep.fromPiece(playerId, piece)).delete) withConnection { implicit conn =>
SQL("""delete from bis
| where player_id = {player_id}
| and piece = {piece}
| and piece_type = {piece_type}""".stripMargin)
.on("player_id" -> playerId, "piece" -> piece.piece, "piece_type" -> piece.pieceType.toString)
.executeUpdate()
}
def deletePiecesBiSById(playerId: Long): Future[Int] = def deletePiecesBiSById(playerId: Long): Future[Int] =
db.run(piecesBiS(Seq(playerId)).delete) withConnection { implicit conn =>
SQL("""delete from bis where player_id = {player_id}""")
.on("player_id" -> playerId)
.executeUpdate()
}
def getPiecesBiSById(playerId: Long): Future[Seq[Loot]] = getPiecesBiSById(Seq(playerId)) def getPiecesBiSById(playerId: Long): Future[Seq[Loot]] = getPiecesBiSById(Seq(playerId))
def getPiecesBiSById(playerIds: Seq[Long]): Future[Seq[Loot]] = def getPiecesBiSById(playerIds: Seq[Long]): Future[Seq[Loot]] =
db.run(piecesBiS(playerIds).result).map(_.map(_.toLoot)) if (playerIds.isEmpty) Future.successful(Seq.empty)
else
withConnection { implicit conn =>
SQL("""select * from bis where player_id in ({player_ids})""")
.on("player_ids" -> playerIds)
.executeQuery()
.as(loot.*)
}
def insertPieceBiSById(piece: Piece)(playerId: Long): Future[Int] = def insertPieceBiSById(piece: Piece)(playerId: Long): Future[Int] =
getPiecesBiSById(playerId).flatMap { withConnection { implicit conn =>
case pieces if pieces.exists(loot => loot.piece.strictEqual(piece)) => Future.successful(0) SQL("""insert into bis
case _ => db.run(bisTable.insertOrUpdate(BiSRep.fromPiece(playerId, piece))) | (player_id, piece, piece_type, job, created)
| values
| ({player_id}, {piece}, {piece_type}, {job}, {created})
| on conflict (player_id, piece, piece_type) do nothing""".stripMargin)
.on(
"player_id" -> playerId,
"piece" -> piece.piece,
"piece_type" -> piece.pieceType.toString,
"job" -> piece.job.toString,
"created" -> DatabaseProfile.now
)
.executeUpdate()
} }
private def pieceBiS(piece: BiSRep) =
piecesBiS(Seq(piece.playerId)).filter { stored =>
(stored.piece === piece.piece) && (stored.pieceType === piece.pieceType)
}
private def piecesBiS(playerIds: Seq[Long]) =
bisTable.filter(_.playerId.inSet(playerIds.toSet))
} }

View File

@ -0,0 +1,58 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.storage
import com.typesafe.config.Config
import com.zaxxer.hikari.HikariConfig
import java.sql.Connection
import java.util.Properties
import javax.sql.DataSource
import scala.concurrent.{ExecutionContext, Future, Promise}
import scala.jdk.CollectionConverters._
import scala.util.control.NonFatal
import scala.util.{Failure, Success, Try}
trait DatabaseConnection {
def datasource: DataSource
def executionContext: ExecutionContext
def withConnection[T](fn: Connection => T): Future[T] = {
val promise = Promise[T]()
executionContext.execute { () =>
Try(datasource.getConnection) match {
case Success(conn) =>
try {
val result = fn(conn)
promise.trySuccess(result)
} catch {
case NonFatal(exception) => promise.tryFailure(exception)
} finally
conn.close()
case Failure(exception) => promise.tryFailure(exception)
}
}
promise.future
}
}
object DatabaseConnection {
def getDataSourceConfig(config: Config): HikariConfig = {
val properties = new Properties()
config.entrySet().asScala.map(_.getKey).foreach { key =>
properties.setProperty(key, config.getString(key))
}
new HikariConfig(properties)
}
}

View File

@ -9,32 +9,33 @@
package me.arcanis.ffxivbis.storage package me.arcanis.ffxivbis.storage
import com.typesafe.config.Config import com.typesafe.config.Config
import com.typesafe.scalalogging.StrictLogging
import com.zaxxer.hikari.HikariDataSource
import me.arcanis.ffxivbis.models.{Loot, Piece, PlayerId} import me.arcanis.ffxivbis.models.{Loot, Piece, PlayerId}
import slick.basic.DatabaseConfig
import slick.jdbc.JdbcProfile
import java.time.Instant import java.time.Instant
import javax.sql.DataSource
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import scala.util.control.NonFatal
class DatabaseProfile(context: ExecutionContext, config: Config) class DatabaseProfile(override val executionContext: ExecutionContext, config: Config)
extends BiSProfile extends StrictLogging
with BiSProfile
with LootProfile with LootProfile
with PartyProfile with PartyProfile
with PlayersProfile with PlayersProfile
with UsersProfile { with UsersProfile {
implicit val executionContext: ExecutionContext = context override val datasource: DataSource =
try {
val dbConfig: DatabaseConfig[JdbcProfile] = val profile = DatabaseProfile.getSection(config)
DatabaseConfig.forConfig[JdbcProfile]("", DatabaseProfile.getSection(config)) val dataSourceConfig = DatabaseConnection.getDataSourceConfig(profile)
import dbConfig.profile.api._ new HikariDataSource(dataSourceConfig)
val db = dbConfig.db } catch {
case NonFatal(exception) =>
val bisTable: TableQuery[BiSPieces] = TableQuery[BiSPieces] logger.error("exception during storage initialization", exception)
val lootTable: TableQuery[LootPieces] = TableQuery[LootPieces] throw exception
val partiesTable: TableQuery[Parties] = TableQuery[Parties] }
val playersTable: TableQuery[Players] = TableQuery[Players]
val usersTable: TableQuery[Users] = TableQuery[Users]
// generic bis api // generic bis api
def deletePieceBiS(playerId: PlayerId, piece: Piece): Future[Int] = def deletePieceBiS(playerId: PlayerId, piece: Piece): Future[Int] =
@ -53,9 +54,8 @@ class DatabaseProfile(context: ExecutionContext, config: Config)
byPlayerId(playerId, insertPieceBiSById(piece)) byPlayerId(playerId, insertPieceBiSById(piece))
// generic loot api // generic loot api
def deletePiece(playerId: PlayerId, piece: Piece): Future[Int] = { def deletePiece(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean): Future[Int] = {
// we don't really care here about loot val loot = Loot(-1, piece, Instant.now, isFreeLoot)
val loot = Loot(-1, piece, Instant.now, isFreeLoot = false)
byPlayerId(playerId, deletePieceById(loot)) byPlayerId(playerId, deletePieceById(loot))
} }
@ -69,21 +69,23 @@ class DatabaseProfile(context: ExecutionContext, config: Config)
byPlayerId(playerId, insertPieceById(loot)) byPlayerId(playerId, insertPieceById(loot))
private def byPartyId[T](partyId: String, callback: Seq[Long] => Future[T]): Future[T] = private def byPartyId[T](partyId: String, callback: Seq[Long] => Future[T]): Future[T] =
getPlayers(partyId).flatMap(callback) getPlayers(partyId).flatMap(callback)(executionContext)
private def byPlayerId[T](playerId: PlayerId, callback: Long => Future[T]): Future[T] = private def byPlayerId[T](playerId: PlayerId, callback: Long => Future[T]): Future[T] =
getPlayer(playerId).flatMap { getPlayer(playerId).flatMap {
case Some(id) => callback(id) case Some(id) => callback(id)
case None => Future.failed(new Error(s"Could not find player $playerId")) case None => Future.failed(DatabaseProfile.PlayerNotFound(playerId))
} }(executionContext)
} }
object DatabaseProfile { object DatabaseProfile {
def now: Long = Instant.now.toEpochMilli case class PlayerNotFound(playerId: PlayerId) extends Exception(s"Could not find player $playerId")
def getSection(config: Config): Config = { def getSection(config: Config): Config = {
val section = config.getString("me.arcanis.ffxivbis.database.mode") val section = config.getString("me.arcanis.ffxivbis.database.mode")
config.getConfig("me.arcanis.ffxivbis.database").getConfig(section) config.getConfig(s"me.arcanis.ffxivbis.database.$section")
} }
def now: Long = Instant.now.toEpochMilli
} }

View File

@ -8,81 +8,80 @@
*/ */
package me.arcanis.ffxivbis.storage package me.arcanis.ffxivbis.storage
import anorm.SqlParser._
import anorm._
import me.arcanis.ffxivbis.models.{Job, Loot, Piece, PieceType} import me.arcanis.ffxivbis.models.{Job, Loot, Piece, PieceType}
import slick.lifted.{ForeignKeyQuery, Index}
import java.time.Instant import java.time.Instant
import scala.concurrent.Future import scala.concurrent.Future
trait LootProfile { this: DatabaseProfile => trait LootProfile extends DatabaseConnection {
import dbConfig.profile.api._
case class LootRep( private val loot: RowParser[Loot] =
lootId: Option[Long], (long("player_id") ~ str("piece") ~ str("piece_type")
playerId: Long, ~ str("job") ~ long("created") ~ int("is_free_loot"))
created: Long, .map { case playerId ~ piece ~ pieceType ~ job ~ created ~ isFreeLoot =>
piece: String, Loot(
pieceType: String, playerId = playerId,
job: String, piece = Piece(
isFreeLoot: Int piece = piece,
) { pieceType = PieceType.withName(pieceType),
job = Job.withName(job)
def toLoot: Loot = Loot( ),
playerId, timestamp = Instant.ofEpochMilli(created),
Piece(piece, PieceType.withName(pieceType), Job.withName(job)), isFreeLoot = isFreeLoot == 1,
Instant.ofEpochMilli(created),
isFreeLoot == 1
) )
} }
object LootRep {
def fromLoot(playerId: Long, loot: Loot): LootRep =
LootRep(
None,
playerId,
loot.timestamp.toEpochMilli,
loot.piece.piece,
loot.piece.pieceType.toString,
loot.piece.job.toString,
if (loot.isFreeLoot) 1 else 0
)
}
class LootPieces(tag: Tag) extends Table[LootRep](tag, "loot") {
def lootId: Rep[Long] = column[Long]("loot_id", O.AutoInc, O.PrimaryKey)
def playerId: Rep[Long] = column[Long]("player_id")
def created: Rep[Long] = column[Long]("created")
def piece: Rep[String] = column[String]("piece")
def pieceType: Rep[String] = column[String]("piece_type")
def job: Rep[String] = column[String]("job")
def isFreeLoot: Rep[Int] = column[Int]("is_free_loot")
def * =
(lootId.?, playerId, created, piece, pieceType, job, isFreeLoot) <> ((LootRep.apply _).tupled, LootRep.unapply)
def fkPlayerId: ForeignKeyQuery[Players, PlayerRep] =
foreignKey("player_id", playerId, playersTable)(_.playerId, onDelete = ForeignKeyAction.Cascade)
def lootOwnerIdx: Index =
index("loot_owner_idx", playerId, unique = false)
}
def deletePieceById(loot: Loot)(playerId: Long): Future[Int] = def deletePieceById(loot: Loot)(playerId: Long): Future[Int] =
db.run(pieceLoot(LootRep.fromLoot(playerId, loot)).map(_.lootId).max.result).flatMap { withConnection { implicit conn =>
case Some(id) => db.run(lootTable.filter(_.lootId === id).delete) SQL("""delete from loot
case _ => throw new IllegalArgumentException(s"Could not find piece $loot belong to $playerId") | where loot_id in
| (
| select loot_id from loot
| where player_id = {player_id}
| and piece = {piece}
| and piece_type = {piece_type}
| and job = {job}
| and is_free_loot = {is_free_loot}
| limit 1
| )""".stripMargin)
.on(
"player_id" -> playerId,
"piece" -> loot.piece.piece,
"piece_type" -> loot.piece.pieceType.toString,
"job" -> loot.piece.job.toString,
"is_free_loot" -> loot.isFreeLootToInt
)
.executeUpdate()
} }
def getPiecesById(playerId: Long): Future[Seq[Loot]] = getPiecesById(Seq(playerId)) def getPiecesById(playerId: Long): Future[Seq[Loot]] = getPiecesById(Seq(playerId))
def getPiecesById(playerIds: Seq[Long]): Future[Seq[Loot]] = def getPiecesById(playerIds: Seq[Long]): Future[Seq[Loot]] =
db.run(piecesLoot(playerIds).result).map(_.map(_.toLoot)) if (playerIds.isEmpty) Future.successful(Seq.empty)
else
withConnection { implicit conn =>
SQL("""select * from loot where player_id in ({player_ids})""")
.on("player_ids" -> playerIds)
.executeQuery()
.as(loot.*)
}
def insertPieceById(loot: Loot)(playerId: Long): Future[Int] = def insertPieceById(loot: Loot)(playerId: Long): Future[Int] =
db.run(lootTable.insertOrUpdate(LootRep.fromLoot(playerId, loot))) withConnection { implicit conn =>
SQL("""insert into loot
private def pieceLoot(piece: LootRep) = | (player_id, piece, piece_type, job, created, is_free_loot)
piecesLoot(Seq(piece.playerId)).filter(_.piece === piece.piece) | values
| ({player_id}, {piece}, {piece_type}, {job}, {created}, {is_free_loot})""".stripMargin)
private def piecesLoot(playerIds: Seq[Long]) = .on(
lootTable.filter(_.playerId.inSet(playerIds.toSet)) "player_id" -> playerId,
"piece" -> loot.piece.piece,
"piece_type" -> loot.piece.pieceType.toString,
"job" -> loot.piece.job.toString,
"created" -> DatabaseProfile.now,
"is_free_loot" -> loot.isFreeLootToInt
)
.executeUpdate()
}
} }

View File

@ -8,47 +8,41 @@
*/ */
package me.arcanis.ffxivbis.storage package me.arcanis.ffxivbis.storage
import anorm.SqlParser._
import anorm._
import me.arcanis.ffxivbis.models.PartyDescription import me.arcanis.ffxivbis.models.PartyDescription
import scala.concurrent.Future import scala.concurrent.Future
trait PartyProfile { this: DatabaseProfile => trait PartyProfile extends DatabaseConnection {
import dbConfig.profile.api._
case class PartyRep(partyId: Option[Long], partyName: String, partyAlias: Option[String]) { private val description: RowParser[PartyDescription] =
(str("party_name") ~ str("party_alias").?)
def toDescription: PartyDescription = PartyDescription(partyName, partyAlias) .map { case partyName ~ partyAlias =>
} PartyDescription(
partyId = partyName,
object PartyRep { partyAlias = partyAlias,
)
def fromDescription(party: PartyDescription, id: Option[Long]): PartyRep =
PartyRep(id, party.partyId, party.partyAlias)
}
class Parties(tag: Tag) extends Table[PartyRep](tag, "parties") {
def partyId: Rep[Long] = column[Long]("party_id", O.AutoInc, O.PrimaryKey)
def partyName: Rep[String] = column[String]("party_name")
def partyAlias: Rep[Option[String]] = column[Option[String]]("party_alias")
def * =
(partyId.?, partyName, partyAlias) <> ((PartyRep.apply _).tupled, PartyRep.unapply)
} }
def getPartyDescription(partyId: String): Future[PartyDescription] = def getPartyDescription(partyId: String): Future[PartyDescription] =
db.run( withConnection { implicit conn =>
partyDescription(partyId).result.headOption.map(_.map(_.toDescription).getOrElse(PartyDescription.empty(partyId))) SQL("""select * from parties where party_name = {party_name}""")
) .on("party_name" -> partyId)
.executeQuery()
def getUniquePartyId(partyId: String): Future[Option[Long]] = .as(description.singleOpt)
db.run(partyDescription(partyId).map(_.partyId).result.headOption) .getOrElse(PartyDescription.empty(partyId))
def insertPartyDescription(partyDescription: PartyDescription): Future[Int] =
getUniquePartyId(partyDescription.partyId).flatMap {
case Some(id) => db.run(partiesTable.update(PartyRep.fromDescription(partyDescription, Some(id))))
case _ => db.run(partiesTable.insertOrUpdate(PartyRep.fromDescription(partyDescription, None)))
} }
private def partyDescription(partyId: String) = def insertPartyDescription(partyDescription: PartyDescription): Future[Int] =
partiesTable.filter(_.partyName === partyId) withConnection { implicit conn =>
SQL("""insert into parties
| (party_name, party_alias)
| values
| ({party_name}, {party_alias})
| on conflict (party_name) do update set
| party_alias = {party_alias}""".stripMargin)
.on("party_name" -> partyDescription.partyId, "party_alias" -> partyDescription.partyAlias)
.executeUpdate()
}
} }

View File

@ -8,76 +8,111 @@
*/ */
package me.arcanis.ffxivbis.storage package me.arcanis.ffxivbis.storage
import anorm.SqlParser._
import anorm._
import me.arcanis.ffxivbis.models.{BiS, Job, Player, PlayerId} import me.arcanis.ffxivbis.models.{BiS, Job, Player, PlayerId}
import scala.concurrent.Future import scala.concurrent.Future
trait PlayersProfile { this: DatabaseProfile => trait PlayersProfile extends DatabaseConnection {
import dbConfig.profile.api._
case class PlayerRep( private val player: RowParser[Player] =
partyId: String, (long("player_id") ~ str("party_id") ~ str("job")
playerId: Option[Long], ~ str("nick") ~ str("bis_link").? ~ int("priority").?)
created: Long, .map { case playerId ~ partyId ~ job ~ nick ~ link ~ priority =>
nick: String, Player(
job: String, id = playerId,
link: Option[String], partyId = partyId,
priority: Int job = Job.withName(job),
) { nick = nick,
bis = BiS.empty,
def toPlayer: Player = loot = Seq.empty,
Player(playerId.getOrElse(-1), partyId, Job.withName(job), nick, BiS.empty, Seq.empty, link, priority) link = link,
priority = priority.getOrElse(0),
)
} }
object PlayerRep { def deletePlayer(playerId: PlayerId): Future[Int] =
withConnection { implicit conn =>
def fromPlayer(player: Player, id: Option[Long]): PlayerRep = SQL("""delete from players
PlayerRep(player.partyId, id, DatabaseProfile.now, player.nick, player.job.toString, player.link, player.priority) | where party_id = {party_id}
| and nick = {nick}
| and job = {job}""".stripMargin)
.on("party_id" -> playerId.partyId, "nick" -> playerId.nick, "job" -> playerId.job.toString)
.executeUpdate()
} }
class Players(tag: Tag) extends Table[PlayerRep](tag, "players") {
def partyId: Rep[String] = column[String]("party_id")
def playerId: Rep[Long] = column[Long]("player_id", O.AutoInc, O.PrimaryKey)
def created: Rep[Long] = column[Long]("created")
def nick: Rep[String] = column[String]("nick")
def job: Rep[String] = column[String]("job")
def bisLink: Rep[Option[String]] = column[Option[String]]("bis_link")
def priority: Rep[Int] = column[Int]("priority", O.Default(1))
def * =
(partyId, playerId.?, created, nick, job, bisLink, priority) <> ((PlayerRep.apply _).tupled, PlayerRep.unapply)
}
def deletePlayer(playerId: PlayerId): Future[Int] = db.run(player(playerId).delete)
def getParty(partyId: String): Future[Map[Long, Player]] = def getParty(partyId: String): Future[Map[Long, Player]] =
db.run(players(partyId).result) withConnection { implicit conn =>
.map(_.foldLeft(Map.empty[Long, Player]) { SQL("""select * from players where party_id = {party_id}""")
case (acc, p @ PlayerRep(_, Some(id), _, _, _, _, _)) => acc + (id -> p.toPlayer) .on("party_id" -> partyId)
case (acc, _) => acc .executeQuery()
}) .as(player.*)
.map(p => p.id -> p)
def getPlayer(playerId: PlayerId): Future[Option[Long]] = .toMap
db.run(player(playerId).map(_.playerId).result.headOption)
def getPlayerFull(playerId: PlayerId): Future[Option[Player]] =
db.run(player(playerId).result.headOption.map(_.map(_.toPlayer)))
def getPlayers(partyId: String): Future[Seq[Long]] =
db.run(players(partyId).map(_.playerId).result)
def insertPlayer(playerObj: Player): Future[Int] =
getPlayer(playerObj.playerId).flatMap {
case Some(id) => db.run(playersTable.update(PlayerRep.fromPlayer(playerObj, Some(id))))
case _ => db.run(playersTable.insertOrUpdate(PlayerRep.fromPlayer(playerObj, None)))
} }
private def player(playerId: PlayerId) = def getPlayer(playerId: PlayerId): Future[Option[Long]] =
playersTable withConnection { implicit conn =>
.filter(_.partyId === playerId.partyId) SQL("""select player_id from players
.filter(_.job === playerId.job.toString) | where party_id = {party_id}
.filter(_.nick === playerId.nick) | and nick = {nick}
| and job = {job}""".stripMargin)
.on("party_id" -> playerId.partyId, "nick" -> playerId.nick, "job" -> playerId.job.toString)
.executeQuery()
.as(scalar[Long].singleOpt)
}
def getPlayerFull(playerId: PlayerId): Future[Option[Player]] =
withConnection { implicit conn =>
SQL("""select * from players
| where party_id = {party_id}
| and nick = {nick}
| and job = {job}""".stripMargin)
.on("party_id" -> playerId.partyId, "nick" -> playerId.nick, "job" -> playerId.job.toString)
.executeQuery()
.as(player.singleOpt)
}
def getPlayers(partyId: String): Future[Seq[Long]] =
withConnection { implicit conn =>
SQL("""select player_id from players where party_id = {party_id}""")
.on("party_id" -> partyId)
.executeQuery()
.as(scalar[Long].*)
}
def insertPlayer(player: Player): Future[Int] =
withConnection { implicit conn =>
SQL("""insert into players
| (party_id, created, job, nick, bis_link, priority)
| values
| ({party_id}, {created}, {job}, {nick}, {link}, {priority})
| on conflict (party_id, nick, job) do update set
| bis_link = {link}, priority = {priority}""".stripMargin)
.on(
"party_id" -> player.partyId,
"created" -> DatabaseProfile.now,
"job" -> player.job.toString,
"nick" -> player.nick,
"link" -> player.link,
"priority" -> player.priority
)
.executeUpdate()
}
def updateBiSLink(playerId: PlayerId, link: String): Future[Int] =
withConnection { implicit conn =>
SQL("""update players
| set bis_link = {link}
| where party_id = {party_id} and nick = {nick} and job = {job}""".stripMargin)
.on(
"link" -> link,
"party_id" -> playerId.partyId,
"nick" -> playerId.nick,
"job" -> playerId.job.toString
)
.executeUpdate()
}
private def players(partyId: String) =
playersTable.filter(_.partyId === partyId)
} }

View File

@ -8,64 +8,67 @@
*/ */
package me.arcanis.ffxivbis.storage package me.arcanis.ffxivbis.storage
import anorm.SqlParser._
import anorm._
import me.arcanis.ffxivbis.models.{Permission, User} import me.arcanis.ffxivbis.models.{Permission, User}
import slick.lifted.{Index, PrimaryKey}
import scala.concurrent.Future import scala.concurrent.Future
trait UsersProfile { this: DatabaseProfile => trait UsersProfile extends DatabaseConnection {
import dbConfig.profile.api._
case class UserRep(partyId: String, userId: Option[Long], username: String, password: String, permission: String) { private val user: RowParser[User] =
(str("party_id") ~ str("username") ~ str("password") ~ str("permission"))
def toUser: User = User(partyId, username, password, Permission.withName(permission)) .map { case partyId ~ username ~ password ~ permission =>
} User(
partyId = partyId,
object UserRep { username = username,
password = password,
def fromUser(user: User, id: Option[Long]): UserRep = permission = Permission.withName(permission),
UserRep(user.partyId, id, user.username, user.password, user.permission.toString) )
}
class Users(tag: Tag) extends Table[UserRep](tag, "users") {
def partyId: Rep[String] = column[String]("party_id")
def userId: Rep[Long] = column[Long]("user_id", O.AutoInc, O.PrimaryKey)
def username: Rep[String] = column[String]("username")
def password: Rep[String] = column[String]("password")
def permission: Rep[String] = column[String]("permission")
def * =
(partyId, userId.?, username, password, permission) <> ((UserRep.apply _).tupled, UserRep.unapply)
def pk: PrimaryKey = primaryKey("users_username_idx", (partyId, username))
def usersUsernameIdx: Index =
index("users_username_idx", (partyId, username), unique = true)
} }
def deleteUser(partyId: String, username: String): Future[Int] = def deleteUser(partyId: String, username: String): Future[Int] =
db.run( withConnection { implicit conn =>
user(partyId, Some(username)) SQL("""delete from users
.filter(_.permission =!= Permission.admin.toString) // we do not allow to remove admins | where party_id = {party_id}
.delete | and username = {username}
) | and permission <> {admin}""".stripMargin)
.on("party_id" -> partyId, "username" -> username, "admin" -> Permission.admin.toString)
def exists(partyId: String): Future[Boolean] = .executeUpdate()
db.run(user(partyId, None).exists.result)
def getUser(partyId: String, username: String): Future[Option[User]] =
db.run(user(partyId, Some(username)).result.headOption).map(_.map(_.toUser))
def getUsers(partyId: String): Future[Seq[User]] =
db.run(user(partyId, None).result).map(_.map(_.toUser))
def insertUser(userObj: User): Future[Int] =
db.run(user(userObj.partyId, Some(userObj.username)).map(_.userId).result.headOption).flatMap {
case Some(id) => db.run(usersTable.insertOrUpdate(UserRep.fromUser(userObj, Some(id))))
case _ => db.run(usersTable.insertOrUpdate(UserRep.fromUser(userObj, None)))
} }
private def user(partyId: String, username: Option[String]) = def exists(partyId: String): Future[Boolean] = getUsers(partyId).map(_.nonEmpty)(executionContext)
usersTable
.filter(_.partyId === partyId) def getUser(partyId: String, username: String): Future[Option[User]] =
.filterIf(username.isDefined)(_.username === username.orNull) withConnection { implicit conn =>
SQL("""select * from users where party_id = {party_id} and username = {username}""")
.on("party_id" -> partyId, "username" -> username)
.executeQuery()
.as(user.singleOpt)
}
def getUsers(partyId: String): Future[Seq[User]] =
withConnection { implicit conn =>
SQL("""select * from users where party_id = {party_id}""")
.on("party_id" -> partyId)
.executeQuery()
.as(user.*)
}
def insertUser(user: User): Future[Int] =
withConnection { implicit conn =>
SQL("""insert into users
| (party_id, username, password, permission)
| values
| ({party_id}, {username}, {password}, {permission})
| on conflict (party_id, username) do update set
| password = {password}, permission = {permission}""".stripMargin)
.on(
"party_id" -> user.partyId,
"username" -> user.username,
"password" -> user.password,
"permission" -> user.permission.toString
)
.executeUpdate()
}
} }

View File

@ -9,22 +9,23 @@
package me.arcanis.ffxivbis.utils package me.arcanis.ffxivbis.utils
import akka.util.Timeout import akka.util.Timeout
import com.typesafe.config.Config
import java.time.Duration
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import scala.concurrent.duration.FiniteDuration import scala.concurrent.duration.FiniteDuration
import scala.language.implicitConversions import scala.language.implicitConversions
import scala.util.Try
object Implicits { object Implicits {
implicit def getBooleanFromOptionString(maybeYes: Option[String]): Boolean = maybeYes.map(_.toLowerCase) match { implicit class ConfigExtension(config: Config) {
case Some("yes" | "on") => true
case _ => false def getFiniteDuration(path: String): FiniteDuration =
FiniteDuration(config.getDuration(path, TimeUnit.MILLISECONDS), TimeUnit.MILLISECONDS)
def getOptString(path: String): Option[String] =
Try(config.getString(path)).toOption.filter(_.nonEmpty)
def getTimeout(path: String): Timeout = getFiniteDuration(path)
} }
implicit def getFiniteDuration(duration: Duration): FiniteDuration =
FiniteDuration(duration.toNanos, TimeUnit.NANOSECONDS)
implicit def getTimeout(duration: Duration): Timeout =
FiniteDuration(duration.toNanos, TimeUnit.NANOSECONDS)
} }

View File

@ -0,0 +1,16 @@
package me.arcanis.ffxivbis
import akka.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
import org.scalatest.wordspec.AnyWordSpecLike
class ApplicationTest extends ScalaTestWithActorTestKit(Settings.withRandomDatabase)
with AnyWordSpecLike {
"application" must {
"load" in {
testKit.spawn[Nothing](Application())
}
}
}

View File

@ -0,0 +1,20 @@
package me.arcanis.ffxivbis
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
class ConfigurationTest extends AnyWordSpecLike with Matchers {
private val requiredPaths =
Seq("akka.http.server.transparent-head-requests")
"configuration helper" must {
requiredPaths.foreach { path =>
s"has $path propery" in {
Configuration.load().hasPath(path) shouldBe true
}
}
}
}

View File

@ -6,49 +6,65 @@ import me.arcanis.ffxivbis.models._
import scala.concurrent.Future import scala.concurrent.Future
object Fixtures { object Fixtures {
lazy val bis: BiS = BiS( lazy val bis: BiS = BiS(
Seq( Seq(
Weapon(pieceType = PieceType.Savage ,Job.DNC), Piece.Weapon(pieceType = PieceType.Savage ,Job.DNC),
Head(pieceType = PieceType.Savage, Job.DNC), Piece.Head(pieceType = PieceType.Savage, Job.DNC),
Body(pieceType = PieceType.Savage, Job.DNC), Piece.Body(pieceType = PieceType.Savage, Job.DNC),
Hands(pieceType = PieceType.Tome, Job.DNC), Piece.Hands(pieceType = PieceType.Tome, Job.DNC),
Legs(pieceType = PieceType.Tome, Job.DNC), Piece.Legs(pieceType = PieceType.Tome, Job.DNC),
Feet(pieceType = PieceType.Savage, Job.DNC), Piece.Feet(pieceType = PieceType.Savage, Job.DNC),
Ears(pieceType = PieceType.Savage, Job.DNC), Piece.Ears(pieceType = PieceType.Savage, Job.DNC),
Neck(pieceType = PieceType.Tome, Job.DNC), Piece.Neck(pieceType = PieceType.Tome, Job.DNC),
Wrist(pieceType = PieceType.Savage, Job.DNC), Piece.Wrist(pieceType = PieceType.Savage, Job.DNC),
Ring(pieceType = PieceType.Tome, Job.DNC, "left ring"), Piece.Ring(pieceType = PieceType.Tome, Job.DNC, "left ring"),
Ring(pieceType = PieceType.Tome, Job.DNC, "right ring") Piece.Ring(pieceType = PieceType.Tome, Job.DNC, "right ring")
) )
) )
lazy val bis2: BiS = BiS( lazy val bis2: BiS = BiS(
Seq( Seq(
Weapon(pieceType = PieceType.Savage ,Job.DNC), Piece.Weapon(pieceType = PieceType.Savage ,Job.DNC),
Head(pieceType = PieceType.Tome, Job.DNC), Piece.Head(pieceType = PieceType.Tome, Job.DNC),
Body(pieceType = PieceType.Savage, Job.DNC), Piece.Body(pieceType = PieceType.Savage, Job.DNC),
Hands(pieceType = PieceType.Tome, Job.DNC), Piece.Hands(pieceType = PieceType.Tome, Job.DNC),
Legs(pieceType = PieceType.Savage, Job.DNC), Piece.Legs(pieceType = PieceType.Savage, Job.DNC),
Feet(pieceType = PieceType.Tome, Job.DNC), Piece.Feet(pieceType = PieceType.Tome, Job.DNC),
Ears(pieceType = PieceType.Savage, Job.DNC), Piece.Ears(pieceType = PieceType.Savage, Job.DNC),
Neck(pieceType = PieceType.Savage, Job.DNC), Piece.Neck(pieceType = PieceType.Savage, Job.DNC),
Wrist(pieceType = PieceType.Savage, Job.DNC), Piece.Wrist(pieceType = PieceType.Savage, Job.DNC),
Ring(pieceType = PieceType.Tome, Job.DNC, "left ring"), Piece.Ring(pieceType = PieceType.Tome, Job.DNC, "left ring"),
Ring(pieceType = PieceType.Savage, Job.DNC, "right ring") Piece.Ring(pieceType = PieceType.Savage, Job.DNC, "right ring")
) )
) )
lazy val bis3: BiS = BiS( lazy val bis3: BiS = BiS(
Seq( Seq(
Weapon(pieceType = PieceType.Savage ,Job.SGE), Piece.Weapon(pieceType = PieceType.Savage ,Job.SGE),
Head(pieceType = PieceType.Tome, Job.SGE), Piece.Head(pieceType = PieceType.Tome, Job.SGE),
Body(pieceType = PieceType.Savage, Job.SGE), Piece.Body(pieceType = PieceType.Savage, Job.SGE),
Hands(pieceType = PieceType.Tome, Job.SGE), Piece.Hands(pieceType = PieceType.Tome, Job.SGE),
Legs(pieceType = PieceType.Tome, Job.SGE), Piece.Legs(pieceType = PieceType.Tome, Job.SGE),
Feet(pieceType = PieceType.Savage, Job.SGE), Piece.Feet(pieceType = PieceType.Savage, Job.SGE),
Ears(pieceType = PieceType.Savage, Job.SGE), Piece.Ears(pieceType = PieceType.Savage, Job.SGE),
Neck(pieceType = PieceType.Tome, Job.SGE), Piece.Neck(pieceType = PieceType.Tome, Job.SGE),
Wrist(pieceType = PieceType.Savage, Job.SGE), Piece.Wrist(pieceType = PieceType.Savage, Job.SGE),
Ring(pieceType = PieceType.Savage, Job.SGE, "left ring"), Piece.Ring(pieceType = PieceType.Savage, Job.SGE, "left ring"),
Ring(pieceType = PieceType.Tome, Job.SGE, "right ring") Piece.Ring(pieceType = PieceType.Tome, Job.SGE, "right ring")
)
)
lazy val bis4: BiS = BiS(
Seq(
Piece.Weapon(pieceType = PieceType.Savage ,Job.VPR),
Piece.Head(pieceType = PieceType.Savage, Job.VPR),
Piece.Body(pieceType = PieceType.Savage, Job.VPR),
Piece.Hands(pieceType = PieceType.Tome, Job.VPR),
Piece.Legs(pieceType = PieceType.Tome, Job.VPR),
Piece.Feet(pieceType = PieceType.Savage, Job.VPR),
Piece.Ears(pieceType = PieceType.Tome, Job.VPR),
Piece.Neck(pieceType = PieceType.Savage, Job.VPR),
Piece.Wrist(pieceType = PieceType.Tome, Job.VPR),
Piece.Ring(pieceType = PieceType.Savage, Job.VPR, "left ring"),
Piece.Ring(pieceType = PieceType.Tome, Job.VPR, "right ring")
) )
) )
@ -57,17 +73,18 @@ object Fixtures {
lazy val link3: String = "https://etro.gg/gearset/26a67536-b4ce-4adc-a46a-f70e348bb138" lazy val link3: String = "https://etro.gg/gearset/26a67536-b4ce-4adc-a46a-f70e348bb138"
lazy val link4: String = "https://etro.gg/gearset/865fc886-994f-4c28-8fc1-4379f160a916" lazy val link4: String = "https://etro.gg/gearset/865fc886-994f-4c28-8fc1-4379f160a916"
lazy val link5: String = "https://ffxiv.ariyala.com/1FGU0" lazy val link5: String = "https://ffxiv.ariyala.com/1FGU0"
lazy val link6: String = "https://xivgear.app/?page=sl%7Cd65b4776-01e1-4269-af74-0bc6e01ca2ec"
lazy val lootWeapon: Piece = Weapon(pieceType = PieceType.Tome, Job.AnyJob) lazy val lootWeapon: Piece = Piece.Weapon(pieceType = PieceType.Tome, Job.AnyJob)
lazy val lootBody: Piece = Body(pieceType = PieceType.Savage, Job.AnyJob) lazy val lootBody: Piece = Piece.Body(pieceType = PieceType.Savage, Job.AnyJob)
lazy val lootBodyCrafted: Piece = Body(pieceType = PieceType.Crafted, Job.AnyJob) lazy val lootBodyCrafted: Piece = Piece.Body(pieceType = PieceType.Crafted, Job.AnyJob)
lazy val lootHands: Piece = Hands(pieceType = PieceType.Tome, Job.AnyJob) lazy val lootHands: Piece = Piece.Hands(pieceType = PieceType.Tome, Job.AnyJob)
lazy val lootLegs: Piece = Legs(pieceType = PieceType.Savage, Job.AnyJob) lazy val lootLegs: Piece = Piece.Legs(pieceType = PieceType.Savage, Job.AnyJob)
lazy val lootEars: Piece = Ears(pieceType = PieceType.Savage, Job.AnyJob) lazy val lootEars: Piece = Piece.Ears(pieceType = PieceType.Savage, Job.AnyJob)
lazy val lootRing: Piece = Ring(pieceType = PieceType.Tome, Job.AnyJob) lazy val lootRing: Piece = Piece.Ring(pieceType = PieceType.Tome, Job.AnyJob)
lazy val lootLeftRing: Piece = Ring(pieceType = PieceType.Tome, Job.AnyJob, "left ring") lazy val lootLeftRing: Piece = Piece.Ring(pieceType = PieceType.Tome, Job.AnyJob, "left ring")
lazy val lootRightRing: Piece = Ring(pieceType = PieceType.Tome, Job.AnyJob, "right ring") lazy val lootRightRing: Piece = Piece.Ring(pieceType = PieceType.Tome, Job.AnyJob, "right ring")
lazy val lootUpgrade: Piece = BodyUpgrade lazy val lootUpgrade: Piece = Piece.BodyUpgrade
lazy val loot: Seq[Piece] = Seq(lootBody, lootHands, lootLegs, lootUpgrade) lazy val loot: Seq[Piece] = Seq(lootBody, lootHands, lootLegs, lootUpgrade)
lazy val partyId: String = Party.randomPartyId lazy val partyId: String = Party.randomPartyId
@ -84,4 +101,5 @@ object Fixtures {
lazy val users: Seq[User] = Seq(userAdmin, userGet) lazy val users: Seq[User] = Seq(userAdmin, userGet)
lazy val authProvider: AuthorizationProvider = (_: String, _: String) => Future.successful(Some(userAdmin)) lazy val authProvider: AuthorizationProvider = (_: String, _: String) => Future.successful(Some(userAdmin))
lazy val rejectingAuthProvider: AuthorizationProvider = (_: String, _: String) => Future.successful(None)
} }

View File

@ -5,11 +5,12 @@ import com.typesafe.config.{Config, ConfigFactory, ConfigValueFactory}
import java.io.File import java.io.File
object Settings { object Settings {
def config(values: Map[String, AnyRef]): Config = { def config(values: Map[String, AnyRef]): Config = {
@scala.annotation.tailrec @scala.annotation.tailrec
def replace(acc: Config, iter: List[(String, AnyRef)]): Config = iter match { def replace(config: Config, iter: List[(String, AnyRef)]): Config = iter match {
case Nil => acc case Nil => config
case (key -> value) :: tail => replace(acc.withValue(key, ConfigValueFactory.fromAnyRef(value)), tail) case (key -> value) :: tail => replace(config.withValue(key, ConfigValueFactory.fromAnyRef(value)), tail)
} }
val default = ConfigFactory.load() val default = ConfigFactory.load()
@ -17,13 +18,15 @@ object Settings {
} }
def clearDatabase(config: Config): Unit = def clearDatabase(config: Config): Unit =
config.getString("me.arcanis.ffxivbis.database.sqlite.db.url").split(":") config.getString("me.arcanis.ffxivbis.database.sqlite.jdbcUrl").split(":")
.lastOption.foreach { databasePath => .lastOption.foreach { databasePath =>
val databaseFile = new File(databasePath) val databaseFile = new File(databasePath)
if (databaseFile.exists) if (databaseFile.exists)
databaseFile.delete() databaseFile.delete()
} }
def randomDatabasePath: String = File.createTempFile("ffxivdb-",".db").toPath.toString def randomDatabasePath: String = File.createTempFile("ffxivdb-",".db").toPath.toString
def withRandomDatabase: Config = def withRandomDatabase: Config =
config(Map("me.arcanis.ffxivbis.database.sqlite.db.url" -> s"jdbc:sqlite:$randomDatabasePath")) config(Map("me.arcanis.ffxivbis.database.sqlite.jdbcUrl" -> s"jdbc:sqlite:$randomDatabasePath"))
} }

View File

@ -0,0 +1,44 @@
package me.arcanis.ffxivbis.http
import akka.http.scaladsl.model.headers.{Authorization, BasicHttpCredentials}
import akka.http.scaladsl.model.{StatusCodes, Uri}
import akka.http.scaladsl.server.Route
import akka.http.scaladsl.testkit.ScalatestRouteTest
import me.arcanis.ffxivbis.Fixtures
import me.arcanis.ffxivbis.http.view.RootView
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
class AuthorizationTest extends AnyWordSpecLike with Matchers with ScalatestRouteTest {
private val auth =
Authorization(BasicHttpCredentials(Fixtures.userAdmin.username, Fixtures.userPassword))
"authorization directive" must {
"accept credentials" in {
val route = new RootView(Fixtures.authProvider).routes
Get(Uri(s"/party/${Fixtures.partyId}")).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
}
}
"reject credentials" in {
val route = new RootView(Fixtures.rejectingAuthProvider).routes
Get(Uri(s"/party/${Fixtures.partyId}")).withHeaders(auth) ~> Route.seal(route) ~> check {
status shouldEqual StatusCodes.Unauthorized
}
}
"reject with empty credentials" in {
val route = new RootView(Fixtures.authProvider).routes
Get(Uri(s"/party/${Fixtures.partyId}")) ~> Route.seal(route) ~> check {
status shouldEqual StatusCodes.Unauthorized
}
}
}
}

View File

@ -0,0 +1,22 @@
package me.arcanis.ffxivbis.http
import akka.http.scaladsl.model.{StatusCodes, Uri}
import akka.http.scaladsl.server.Directives.complete
import akka.http.scaladsl.testkit.ScalatestRouteTest
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
class HttpLogTest extends AnyWordSpecLike with Matchers with ScalatestRouteTest {
private val log = new HttpLog {}
"log directive" must {
"work with empty request" in {
Get(Uri("/")) ~> log.withHttpLog(complete(StatusCodes.OK)) ~> check {
status shouldEqual StatusCodes.OK
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More