Compare commits

...

47 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
c5b0832d29 Release 0.10.1 2022-01-15 23:22:50 +03:00
b36240765a change job requirements 2022-01-15 23:21:42 +03:00
4e3066e0a3 Release 0.10.0 2022-01-15 23:18:08 +03:00
eeb5178efc
migrate to bootstrap (#14) 2022-01-15 23:15:24 +03:00
150 changed files with 4418 additions and 2829 deletions

View File

@ -25,9 +25,9 @@ jobs:
uses: actions/setup-java@v2
with:
distribution: temurin
java-version: 8
java-version: 18
- name: create dist
run: sbt -v dist
run: make dist
- name: release
uses: softprops/action-gh-release@v1
with:

View File

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

1
.gitignore vendored
View File

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

35
Makefile Normal file
View File

@ -0,0 +1,35 @@
.PHONY: check clean compile dist push tests version
.DEFAULT_GOAL := compile
PROJECT := ffxivbis
check:
sbt scalafmtCheck
clean:
sbt clean
compile: clean
sbt compile
format:
sbt scalafmt
dist: tests
sbt dist
push: version dist
git add version.sbt
git commit -m "Release $(VERSION)"
git tag "$(VERSION)"
git push
git push --tags
tests: compile check
sbt test
version:
ifndef VERSION
$(error VERSION is required, but not set)
endif
sed -i '/version := "[0-9.]*/s/[^"][^)]*/version := "$(VERSION)"/' version.sbt

View File

@ -22,10 +22,10 @@ from the extracted archive root.
## 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.
## 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"
scalaVersion := "2.13.6"
scalaVersion := "2.13.12"
scalacOptions ++= Seq("-deprecation", "-feature")
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,28 +1,28 @@
val AkkaVersion = "2.6.17"
val AkkaHttpVersion = "10.2.7"
val ScalaTestVersion = "3.2.10"
val SlickVersion = "3.3.3"
val AkkaVersion = "2.8.6"
val AkkaHttpVersion = "10.5.3"
val ScalaTestVersion = "3.2.19"
libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.9"
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.4"
libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.5.6"
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.5"
libraryDependencies += "com.typesafe.akka" %% "akka-http" % AkkaHttpVersion
libraryDependencies += "com.typesafe.akka" %% "akka-http-spray-json" % AkkaHttpVersion
libraryDependencies += "com.typesafe.akka" %% "akka-actor-typed" % AkkaVersion
libraryDependencies += "com.typesafe.akka" %% "akka-stream" % AkkaVersion
libraryDependencies += "com.github.swagger-akka-http" %% "swagger-akka-http" % "2.6.0"
libraryDependencies += "jakarta.platform" % "jakarta.jakartaee-web-api" % "9.1.0"
libraryDependencies += "com.github.swagger-akka-http" %% "swagger-akka-http" % "2.11.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 += "com.lihaoyi" %% "scalatags" % "0.9.2"
libraryDependencies += "com.typesafe.slick" %% "slick" % SlickVersion
libraryDependencies += "com.typesafe.slick" %% "slick-hikaricp" % SlickVersion
libraryDependencies += "org.flywaydb" % "flyway-core" % "8.2.2"
libraryDependencies += "org.xerial" % "sqlite-jdbc" % "3.36.0.3"
libraryDependencies += "org.postgresql" % "postgresql" % "42.3.1"
libraryDependencies += "org.playframework.anorm" %% "anorm" % "2.7.0"
libraryDependencies += "com.zaxxer" % "HikariCP" % "5.1.0" exclude("org.slf4j", "slf4j-api")
libraryDependencies += "org.flywaydb" % "flyway-core" % "9.16.0"
libraryDependencies += "org.xerial" % "sqlite-jdbc" % "3.46.0.0"
libraryDependencies += "org.postgresql" % "postgresql" % "42.7.3"
libraryDependencies += "org.mindrot" % "jbcrypt" % "0.4"
libraryDependencies += "com.google.guava" % "guava" % "31.0.1-jre"
// testing
libraryDependencies += "org.scalactic" %% "scalactic" % 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.scoverage" % "sbt-scoverage" % "1.9.3")
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

@ -0,0 +1,366 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Best in slot</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="/static/favicon.ico">
<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 rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.3/font/bootstrap-icons.css" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.20.2/dist/bootstrap-table.min.css" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" type="text/css">
<link rel="stylesheet" href="/static/styles.css" type="text/css">
</head>
<body>
<div class="container">
<nav class="navbar navbar-expand-lg justify-content-between align-items-center border-bottom">
<a class="navbar-brand" id="navbar-title">Party</a>
<ul class="navbar-nav">
<a class="nav-item nav-link" id="navbar-bis">best in slot</a>
<a class="nav-item nav-link" id="navbar-loot">looted items</a>
</ul>
<ul class="navbar-nav">
<a class="nav-item nav-link" id="navbar-users">users</a>
</ul>
</nav>
</div>
<div id="alert-placeholder" class="container"></div>
<div class="container">
<h2>Best in slot</h2>
</div>
<div class="container">
<div id="toolbar">
<button id="update-btn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#update-bis-dialog" hidden>
<i class="bi bi-plus"></i> update
</button>
<button class="btn btn-secondary" onclick="reload()">
<i class="bi bi-arrow-clockwise"></i> reload
</button>
<button id="remove-btn" class="btn btn-danger" onclick="removePiece()" disabled hidden>
<i class="bi bi-trash"></i> remove
</button>
</div>
<table id="bis" class="table table-striped table-hover"
data-click-to-select="true"
data-export-options='{"fileName": "bis"}'
data-page-list="[25, 50, 100, all]"
data-page-size="25"
data-pagination="true"
data-resizable="true"
data-search="true"
data-show-columns="true"
data-show-columns-search="true"
data-show-columns-toggle-all="true"
data-show-export="true"
data-show-fullscreen="true"
data-show-search-clear-button="true"
data-single-select="true"
data-sortable="true"
data-sort-name="nick"
data-sort-order="asc"
data-sort-reset="true"
data-toolbar="#toolbar">
<thead class="table-primary">
<tr>
<th data-checkbox="true"></th>
<th data-sortable="true" data-switchable="false" data-field="nick">nick</th>
<th data-sortable="true" data-switchable="false" data-field="job">job</th>
<th data-sortable="true" data-field="piece">piece</th>
<th data-sortable="true" data-field="pieceType">piece type</th>
</tr>
</thead>
</table>
</div>
<div id="update-bis-dialog" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<form class="modal-content" action="javascript:" onsubmit="updateBis()">
<div class="modal-header form-group row">
<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>
<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">
<label class="btn btn-outline-primary" for="update-bis-btn">update bis</label>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
</div>
<div class="modal-body">
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="player">player</label>
<div class="col-sm-8">
<select id="player" name="player" class="form-control" title="player" required></select>
</div>
</div>
<div id="piece-row" class="form-group row">
<label class="col-sm-4 col-form-label" for="piece">piece</label>
<div class="col-sm-8">
<select id="piece" name="piece" class="form-control" title="piece"></select>
</div>
</div>
<div id="piece-type-row" class="form-group row">
<label class="col-sm-4 col-form-label" for="piece-type">piece type</label>
<div class="col-sm-8">
<select id="piece-type" name="pieceType" class="form-control" title="piece-type"></select>
</div>
</div>
<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>
<div class="col-sm-8">
<input id="bis-link" name="link" class="form-control" placeholder="link to bis">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button id="submit-add-bis-btn" type="submit" class="btn btn-primary">add</button>
<button id="submit-set-bis-btn" type="submit" class="btn btn-primary" style="display: none">set</button>
</div>
</div>
</form>
</div>
</div>
<div class="container">
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
<ul class="nav">
<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 class="nav">
<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/issues" title="issues tracker">report a bug</a></li>
</ul>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script>
<script src="https://unpkg.com/tableexport.jquery.plugin/tableExport.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://unpkg.com/bootstrap-table@1.20.2/dist/bootstrap-table.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.20.2/dist/extensions/resizable/bootstrap-table-resizable.js"></script>
<script src="/static/utils.js"></script>
<script src="/static/load.js"></script>
<script>
const [partyId, isReadOnly] = getPartyId();
const table = $("#bis");
const removeButton = $("#remove-btn");
const updateButton = $("#update-btn");
const submitAddBisButton = $("#submit-add-bis-btn");
const submitSetBisButton = $("#submit-set-bis-btn");
const updateBisDialog = $("#update-bis-dialog");
const addPieceButton = $("#add-piece-btn");
const updateBisButton = $("#update-bis-btn");
const bisLinkRow = $("#bis-link-row");
const pieceRow = $("#piece-row");
const pieceTypeRow = $("#piece-type-row");
const linkInput = $("#bis-link");
const pieceInput = $("#piece");
const pieceTypeInput = $("#piece-type");
const playerInput = $("#player");
function addPiece() {
const player = getCurrentOption(playerInput);
$.ajax({
url: `/api/v1/party/${partyId}/bis`,
data: JSON.stringify({
action: "add",
piece: {
pieceType: pieceTypeInput.val(),
job: player.dataset.job,
piece: pieceInput.val(),
},
playerId: {
partyId: partyId,
nick: player.dataset.nick,
job: player.dataset.job,
},
}),
type: "POST",
contentType: "application/json",
success: _ => { reload(); },
error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
});
updateBisDialog.modal("hide");
return true; // action expects boolean result
}
function hideControls() {
removeButton.attr("hidden", isReadOnly);
updateButton.attr("hidden", isReadOnly);
}
function hideLinkPart() {
bisLinkRow.hide();
linkInput.prop("required", false);
submitSetBisButton.hide();
pieceRow.show();
pieceTypeRow.show();
pieceInput.prop("required", true);
pieceTypeInput.prop("required", true);
submitAddBisButton.show();
}
function hidePiecePart() {
bisLinkRow.show();
linkInput.prop("required", true);
submitSetBisButton.show();
pieceRow.hide();
pieceTypeRow.hide();
pieceInput.prop("required", false);
pieceTypeInput.prop("required", false);
submitAddBisButton.hide();
}
function reload() {
table.bootstrapTable("showLoading");
$.ajax({
url: `/api/v1/party/${partyId}`,
type: "GET",
dataType: "json",
success: response => {
const items = response.map(player => {
return player.bis.map(loot => {
return {
nick: player.nick,
job: player.job,
piece: loot.piece,
pieceType: loot.pieceType,
};
});
});
const payload = items.reduce((left, right) => { return left.concat(right); }, []);
table.bootstrapTable("load", payload);
table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading");
const options = response.map(player => {
const option = document.createElement("option");
option.innerText = formatPlayerId(player);
option.dataset.nick = player.nick;
option.dataset.job = player.job;
return option;
});
playerInput.empty().append(options);
},
error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
});
}
function removePiece() {
const pieces = table.bootstrapTable("getSelections");
pieces.map(loot => {
$.ajax({
url: `/api/v1/party/${partyId}/bis`,
data: JSON.stringify({
action: "remove",
piece: {
pieceType: loot.pieceType,
job: loot.job,
piece: loot.piece,
},
playerId: {
partyId: partyId,
job: loot.job,
nick: loot.nick,
},
}),
type: "POST",
contentType: "application/json",
success: _ => { reload(); },
error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
});
});
}
function reset() {
if (updateBisButton.is(":checked")) {
hidePiecePart();
}
if (addPieceButton.is(":checked")) {
hideLinkPart();
}
}
function setBis() {
const player = getCurrentOption(playerInput);
$.ajax({
url: `/api/v1/party/${partyId}/bis`,
data: JSON.stringify({
link: linkInput.val(),
playerId: {
partyId: partyId,
nick: player.dataset.nick,
job: player.dataset.job,
},
}),
type: "PUT",
contentType: "application/json",
success: _ => { reload(); },
error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
});
updateBisDialog.modal("hide");
return true; // action expects boolean result
}
function updateBis() {
if (updateBisButton.is(":checked")) {
return setBis();
}
if (addPieceButton.is(":checked")) {
return addPiece();
}
return false; // should not happen
}
$(() => {
setupFormClear(updateBisDialog, reset);
setupRemoveButton(table, removeButton);
loadHeader(partyId);
loadVersion();
loadTypes("/api/v1/types/pieces", pieceInput);
loadTypes("/api/v1/types/pieces/types", pieceTypeInput);
hideControls();
updateBisButton.click(() => { reset(); });
addPieceButton.click(() => { reset(); });
table.bootstrapTable({});
reload();
reset();
});
</script>
</body>
</html>

View File

@ -0,0 +1,190 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FFXIV loot helper</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="/static/favicon.ico">
<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 rel="stylesheet" href="/static/styles.css" type="text/css">
</head>
<body>
<div id="alert-placeholder" class="container"></div>
<div class="container mb-5">
<div class="form-group row">
<div class="btn-group" role="group" aria-label="Sign in">
<input id="signin-btn" name="signin" type="radio" class="btn-check" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="signin-btn">login to existing party</label>
<input id="signup-btn" name="signin" type="radio" class="btn-check" autocomplete="off">
<label class="btn btn-outline-primary" for="signup-btn">create a new party</label>
</div>
</div>
</div>
<form id="signup-form" class="container mb-5" style="display: none">
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="alias">party alias</label>
<div class="col-sm-10">
<input id="alias" name="alias" class="form-control" placeholder="alias">
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="username">username</label>
<div class="col-sm-10">
<input id="username" name="username" class="form-control" placeholder="admin user name" onkeyup="disableAddButton()">
</div>
</div>
<div class="form-group row">
<label for="password" class="col-sm-2 col-form-label">password</label>
<div class="col-sm-10">
<input id="password" name="password" type="password" class="form-control" placeholder="admin password" onkeyup="disableAddButton()">
</div>
</div>
<div class="form-group row">
<div class="col-sm-10">
<button id="add-btn" type="button" class="btn btn-primary" onclick="createParty()" disabled>add</button>
</div>
</div>
</form>
<form id="signin-form" class="container mb-5">
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="party-id">party id</label>
<div class="col-sm-10">
<input id="party-id" name="partyId" class="form-control" placeholder="id" onkeyup="disableRedirectButton()">
</div>
</div>
<div class="form-group row">
<div class="col-sm-10">
<button id="redirect-btn" type="button" class="btn btn-primary" onclick="redirectToParty()" disabled>go</button>
</div>
</div>
</form>
<div class="container">
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
<ul class="nav">
<li><a class="nav-link" href="/api-docs" title="api">api</a></li>
</ul>
<ul class="nav">
<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/issues" title="issues tracker">report a bug</a></li>
</ul>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.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="/static/utils.js"></script>
<script src="/static/load.js"></script>
<script>
const signinButton = $("#signin-btn");
const signupButton = $("#signup-btn");
const addButton = $("#add-btn");
const redirectButton = $("#redirect-btn");
const signinForm = $("#signin-form");
const signupForm = $("#signup-form");
const aliasInput = $("#alias");
const partyIdInput = $("#party-id");
const passwordInput = $("#password");
const usernameInput = $("#username");
function createDescription(partyId) {
$.ajax({
url: `/api/v1/party/${partyId}/description`,
data: JSON.stringify({
partyId: partyId,
partyAlias: aliasInput.val(),
}),
type: "POST",
contentType: "application/json",
success: function (_) { doRedirect(partyId); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
function createParty() {
$.ajax({
url: `/api/v1/party`,
data: JSON.stringify({
partyId: "",
username: usernameInput.val(),
password: passwordInput.val(),
permission: "admin",
}),
type: "POST",
contentType: "application/json",
dataType: "json",
success: function (data) {
if (aliasInput.val()) {
createDescription(data.partyId);
} else {
doRedirect(data.partyId);
}
},
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
function disableAddButton() {
addButton.attr("disabled", !(passwordInput.val() && usernameInput.val()));
}
function disableRedirectButton() {
redirectButton.attr("disabled", !partyIdInput.val());
}
function doRedirect(partyId) {
location.href = `/party/${partyId}`;
}
function hideSigninPart() {
signinForm.hide();
signupForm.show();
}
function hideSignupPart() {
signinForm.show();
signupForm.hide();
}
function redirectToParty() {
return doRedirect(partyIdInput.val());
}
function reset() {
signinForm.trigger("reset");
signupForm.trigger("reset");
if (signinButton.is(":checked")) {
hideSignupPart();
}
if (signupButton.is(":checked")) {
hideSigninPart();
}
}
$(function () {
loadVersion();
signinButton.click(function () { reset(); });
signupButton.click(function () { reset(); });
});
</script>
</body>
</html>

View File

@ -0,0 +1,358 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Loot table</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="/static/favicon.ico">
<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 rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.3/font/bootstrap-icons.css" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.20.2/dist/bootstrap-table.min.css" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" type="text/css">
<link rel="stylesheet" href="/static/styles.css" type="text/css">
</head>
<body>
<div class="container">
<nav class="navbar navbar-expand-lg justify-content-between align-items-center border-bottom">
<a class="navbar-brand" id="navbar-title">Party</a>
<ul class="navbar-nav">
<a class="nav-item nav-link" id="navbar-bis">best in slot</a>
<a class="nav-item nav-link" id="navbar-loot">looted items</a>
</ul>
<ul class="navbar-nav">
<a class="nav-item nav-link" id="navbar-users">users</a>
</ul>
</nav>
</div>
<div id="alert-placeholder" class="container"></div>
<div class="container">
<h2>Looted items</h2>
</div>
<div class="container">
<div id="toolbar">
<button id="add-btn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#add-loot-dialog" hidden>
<i class="bi bi-plus"></i> add
</button>
<button class="btn btn-secondary" onclick="reload()">
<i class="bi bi-arrow-clockwise"></i> reload
</button>
<button id="remove-btn" class="btn btn-danger" onclick="removeLoot()" disabled hidden>
<i class="bi bi-trash"></i> remove
</button>
</div>
<table id="loot" class="table table-striped table-hover"
data-click-to-select="true"
data-export-options='{"fileName": "loot"}'
data-page-list="[25, 50, 100, all]"
data-page-size="25"
data-pagination="true"
data-resizable="true"
data-search="true"
data-show-columns="true"
data-show-columns-search="true"
data-show-columns-toggle-all="true"
data-show-export="true"
data-show-fullscreen="true"
data-show-search-clear-button="true"
data-single-select="true"
data-sortable="true"
data-sort-name="timestamp"
data-sort-order="desc"
data-sort-reset="true"
data-toolbar="#toolbar">
<thead class="table-primary">
<tr>
<th data-checkbox="true"></th>
<th data-sortable="true" data-switchable="false" data-field="nick">nick</th>
<th data-sortable="true" data-switchable="false" data-field="job">job</th>
<th data-sortable="true" data-field="piece">piece</th>
<th data-sortable="true" data-field="pieceType">piece type</th>
<th data-sortable="true" data-field="isFreeLoot">is free loot</th>
<th data-sortable="true" data-field="timestamp">date</th>
</tr>
</thead>
</table>
</div>
<div id="add-loot-dialog" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog modal-lg" role="document">
<form class="modal-content" action="javascript:" onsubmit="addLootModal()">
<div class="modal-header">
<h4 class="modal-title">add looted piece</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="player">player</label>
<div class="col-sm-8">
<select id="player" name="player" class="form-control" title="player" required></select>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="piece">piece</label>
<div class="col-sm-8">
<select id="piece" name="piece" class="form-control" title="piece" required></select>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="piece-type">piece type</label>
<div class="col-sm-8">
<select id="piece-type" name="pieceType" class="form-control" title="pieceType" required></select>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="job">job</label>
<div class="col-sm-8">
<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>
<table id="stats" class="table table-striped table-hover">
<thead class="table-primary">
<tr>
<th data-formatter="addLootFormatter"></th>
<th data-field="nick">nick</th>
<th data-field="job">job</th>
<th data-field="isRequired">required</th>
<th data-field="lootCount">these pieces looted</th>
<th data-field="lootCountBiS">total bis pieces looted</th>
<th data-field="lootCountTotal">total pieces looted</th>
</tr>
</thead>
</table>
</div>
<div class="modal-footer">
<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="submit" class="btn btn-primary">add</button>
</div>
</form>
</div>
</div>
<div class="container">
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
<ul class="nav">
<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 class="nav">
<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/issues" title="issues tracker">report a bug</a></li>
</ul>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script>
<script src="https://unpkg.com/tableexport.jquery.plugin/tableExport.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://unpkg.com/bootstrap-table@1.20.2/dist/bootstrap-table.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.20.2/dist/extensions/resizable/bootstrap-table-resizable.js"></script>
<script src="/static/utils.js"></script>
<script src="/static/load.js"></script>
<script>
const [partyId, isReadOnly] = getPartyId();
const table = $("#loot");
const stats = $("#stats");
const addButton = $("#add-btn");
const removeButton = $("#remove-btn");
const addLootDialog = $("#add-loot-dialog");
const freeLootInput = $("#free-loot");
const jobInput = $("#job");
const pieceInput = $("#piece");
const pieceTypeInput = $("#piece-type");
const playerInput = $("#player");
function addLoot(nick, job) {
$.ajax({
url: `/api/v1/party/${partyId}/loot`,
data: JSON.stringify({
action: "add",
piece: {
pieceType: pieceTypeInput.val(),
job: job,
piece: pieceInput.val(),
},
playerId: {
partyId: partyId,
nick: nick,
job: job,
},
isFreeLoot: freeLootInput.is(":checked"),
}),
type: "POST",
contentType: "application/json",
success: _ => {
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() {
addButton.attr("hidden", isReadOnly);
removeButton.attr("hidden", isReadOnly);
}
function reload() {
table.bootstrapTable("showLoading");
$.ajax({
url: `/api/v1/party/${partyId}`,
type: "GET",
dataType: "json",
success: response => {
const items = response.map(player => {
return player.loot.map(loot => {
return {
nick: player.nick,
job: player.job,
piece: loot.piece.piece,
pieceType: loot.piece.pieceType,
isFreeLoot: loot.isFreeLoot ? "yes" : "no",
timestamp: loot.timestamp,
};
});
});
const payload = items.reduce((left, right) => { return left.concat(right); }, []);
table.bootstrapTable("load", payload);
table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading");
const options = response.map(player => {
const option = document.createElement("option");
option.innerText = formatPlayerId(player);
option.dataset.nick = player.nick;
option.dataset.job = player.job;
return option;
});
playerInput.empty().append(options);
},
error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
});
}
function removeLoot() {
const pieces = table.bootstrapTable("getSelections");
pieces.map(loot => {
$.ajax({
url: `/api/v1/party/${partyId}/loot`,
data: JSON.stringify({
action: "remove",
piece: {
pieceType: loot.pieceType,
job: loot.job,
piece: loot.piece,
},
playerId: {
partyId: partyId,
nick: loot.nick,
job: loot.job,
},
isFreeLoot: loot.isFreeLoot === "yes",
}),
type: "POST",
contentType: "application/json",
success: _ => { reload(); },
error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
});
});
}
function suggestLoot() {
stats.bootstrapTable("showLoading");
$.ajax({
url: `/api/v1/party/${partyId}/loot`,
data: JSON.stringify({
pieceType: pieceTypeInput.val(),
job: jobInput.val(),
piece: pieceInput.val(),
}),
type: "PUT",
contentType: "application/json",
dataType: "json",
success: response => {
const payload = response.map(stat => {
return {
nick: stat.nick,
job: stat.job,
isRequired: stat.isRequired ? "yes" : "no",
lootCount: stat.lootCount,
lootCountBiS: stat.lootCountBiS,
lootCountTotal: stat.lootCountTotal,
};
});
stats.bootstrapTable("load", payload);
stats.bootstrapTable("uncheckAll");
stats.bootstrapTable("hideLoading");
},
error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
});
}
$(() => {
setupFormClear(addLootDialog);
setupRemoveButton(table, removeButton);
loadVersion();
loadHeader(partyId);
loadTypes("/api/v1/types/jobs/all", jobInput);
loadTypes("/api/v1/types/pieces", pieceInput);
loadTypes("/api/v1/types/pieces/types", pieceTypeInput);
hideControls();
table.bootstrapTable({});
stats.bootstrapTable({});
reload();
});
</script>
</body>
</html>

View File

@ -0,0 +1,259 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FFXIV loot helper</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="/static/favicon.ico">
<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 rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.3/font/bootstrap-icons.css" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.20.2/dist/bootstrap-table.min.css" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" type="text/css">
<link rel="stylesheet" href="/static/styles.css" type="text/css">
</head>
<body>
<div class="container">
<nav class="navbar navbar-expand-lg justify-content-between align-items-center border-bottom">
<a class="navbar-brand" id="navbar-title">Party</a>
<ul class="navbar-nav">
<a class="nav-item nav-link" id="navbar-bis">best in slot</a>
<a class="nav-item nav-link" id="navbar-loot">looted items</a>
</ul>
<ul class="navbar-nav">
<a class="nav-item nav-link" id="navbar-users">users</a>
</ul>
</nav>
</div>
<div id="alert-placeholder" class="container"></div>
<div class="container">
<div id="toolbar">
<button id="add-btn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#add-player-dialog" hidden>
<i class="bi bi-plus"></i> add
</button>
<button class="btn btn-secondary" onclick="reload()">
<i class="bi bi-arrow-clockwise"></i> reload
</button>
<button id="remove-btn" class="btn btn-danger" onclick="removePlayers()" disabled hidden>
<i class="bi bi-trash"></i> remove
</button>
</div>
<table id="players" class="table table-striped table-hover"
data-click-to-select="true"
data-export-options='{"fileName": "players"}'
data-page-list="[25, 50, 100, all]"
data-page-size="25"
data-pagination="true"
data-resizable="true"
data-search="true"
data-show-columns="true"
data-show-columns-search="true"
data-show-columns-toggle-all="true"
data-show-export="true"
data-show-fullscreen="true"
data-show-search-clear-button="true"
data-single-select="true"
data-sortable="true"
data-sort-name="nick"
data-sort-order="asc"
data-sort-reset="true"
data-toolbar="#toolbar">
<thead class="table-primary">
<tr>
<th data-checkbox="true"></th>
<th data-sortable="true" data-switchable="false" data-field="nick">nick</th>
<th data-sortable="true" data-field="job">job</th>
<th data-sortable="true" data-field="link" data-formatter="bisLinkFormatter">best in slot link</th>
<th data-sortable="true" data-field="lootCountBiS">total bis pieces looted</th>
<th data-sortable="true" data-field="lootCountTotal">total pieces looted</th>
<th data-sortable="true" data-field="priority">priority</th>
</tr>
</thead>
</table>
</div>
<div id="add-player-dialog" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<form class="modal-content" action="javascript:" onsubmit="addPlayer()">
<div class="modal-header">
<h4 class="modal-title">add new player</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="nick">player name</label>
<div class="col-sm-8">
<input id="nick" name="nick" class="form-control" placeholder="nick" required>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="job">player job</label>
<div class="col-sm-8">
<select id="job" name="job" class="form-control" title="job" required></select>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="link">link to best in slot</label>
<div class="col-sm-8">
<input id="link" name="link" class="form-control" placeholder="link to bis">
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="priority">priority</label>
<div class="col-sm-8">
<input id="priority" name="priority" type="number" class="form-control" value="0">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button type="submit" class="btn btn-primary">add</button>
</div>
</form>
</div>
</div>
<div class="container">
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
<ul class="nav">
<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 class="nav">
<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/issues" title="issues tracker">report a bug</a></li>
</ul>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script>
<script src="https://unpkg.com/tableexport.jquery.plugin/tableExport.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://unpkg.com/bootstrap-table@1.20.2/dist/bootstrap-table.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.20.2/dist/extensions/resizable/bootstrap-table-resizable.js"></script>
<script src="/static/utils.js"></script>
<script src="/static/load.js"></script>
<script>
const [partyId, isReadOnly] = getPartyId();
const table = $("#players");
const addButton = $("#add-btn");
const removeButton = $("#remove-btn");
const addPlayerDialog = $("#add-player-dialog");
const jobInput = $("#job");
const linkInput = $("#link");
const nickInput = $("#nick");
const priorityInput = $("#priority");
function addPlayer() {
$.ajax({
url: `/api/v1/party/${partyId}`,
data: JSON.stringify({
action: "add",
playerId: {
partyId: partyId,
job: jobInput.val(),
nick: nickInput.val(),
link: linkInput.val() || null,
priority: parseInt(priorityInput.val(), 10),
},
}),
type: "POST",
contentType: "application/json",
success: _ => { reload(); },
error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
});
addPlayerDialog.modal("hide");
return true; // action expects boolean result
}
function bisLinkFormatter(link, row) {
if (link) {
return `<a href="${safe(link)}" title="${safe(row.nick)} best in slot for ${safe(row.job)}">${safe(link)}</a>`;
} else {
return "-";
}
}
function hideControls() {
addButton.attr("hidden", isReadOnly);
removeButton.attr("hidden", isReadOnly);
}
function reload() {
table.bootstrapTable("showLoading");
$.ajax({
url: `/api/v1/party/${partyId}`,
type: "GET",
dataType: "json",
success: response => {
table.bootstrapTable("load", response);
table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading");
},
error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
});
}
function removePlayers() {
const players = table.bootstrapTable("getSelections");
players.map(player => {
$.ajax({
url: `/api/v1/party/${partyId}`,
data: JSON.stringify({
action: "remove",
playerId: {
partyId: partyId,
job: player.job,
nick: player.nick,
},
}),
type: "POST",
contentType: "application/json",
success: _ => { reload(); },
error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
});
});
}
$(() => {
setupFormClear(addPlayerDialog);
setupRemoveButton(table, removeButton);
loadVersion();
loadHeader(partyId);
loadTypes("/api/v1/types/jobs", jobInput);
hideControls();
table.bootstrapTable({});
reload();
});
</script>
</body>
</html>

View File

@ -1,24 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>FFXIV loot tracker API</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<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">
<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</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

@ -0,0 +1,231 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>User management</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="/static/favicon.ico">
<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 rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.3/font/bootstrap-icons.css" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.20.2/dist/bootstrap-table.min.css" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" type="text/css">
<link rel="stylesheet" href="/static/styles.css" type="text/css">
</head>
<body>
<div class="container">
<nav class="navbar navbar-expand-lg justify-content-between align-items-center border-bottom">
<a class="navbar-brand" id="navbar-title">Party</a>
<ul class="navbar-nav">
<a class="nav-item nav-link" id="navbar-bis">best in slot</a>
<a class="nav-item nav-link" id="navbar-loot">looted items</a>
</ul>
<ul class="navbar-nav">
<a class="nav-item nav-link" id="navbar-users">users</a>
</ul>
</nav>
</div>
<div id="alert-placeholder" class="container"></div>
<div class="container">
<h2>Users</h2>
</div>
<div class="container">
<div id="toolbar">
<button id="add-btn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#add-user-dialog" hidden>
<i class="bi bi-plus"></i> add
</button>
<button class="btn btn-secondary" onclick="reload()">
<i class="bi bi-arrow-clockwise"></i> reload
</button>
<button id="remove-btn" class="btn btn-danger" onclick="removeUsers()" disabled hidden>
<i class="bi bi-trash"></i> remove
</button>
</div>
<table id="users" class="table table-striped table-hover"
data-click-to-select="true"
data-export-options='{"fileName": "users"}'
data-page-list="[25, 50, 100, all]"
data-page-size="25"
data-pagination="true"
data-resizable="true"
data-search="true"
data-show-columns="true"
data-show-columns-search="true"
data-show-columns-toggle-all="true"
data-show-export="true"
data-show-fullscreen="true"
data-show-search-clear-button="true"
data-single-select="true"
data-sortable="true"
data-sort-name="username"
data-sort-order="asc"
data-sort-reset="true"
data-toolbar="#toolbar">
<thead class="table-primary">
<tr>
<th data-checkbox="true"></th>
<th data-sortable="true" data-switchable="false" data-field="username">username</th>
<th data-sortable="true" data-field="permission">permission</th>
</tr>
</thead>
</table>
</div>
<div id="add-user-dialog" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<form class="modal-content" action="javascript:" onsubmit="addUser()">
<div class="modal-header">
<h4 class="modal-title">add new user</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="username">login</label>
<div class="col-sm-8">
<input id="username" name="username" class="form-control" placeholder="username" required>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="password">password</label>
<div class="col-sm-8">
<input id="password" name="password" type="password" class="form-control" placeholder="password" required>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="permission">permission</label>
<div class="col-sm-8">
<select id="permission" name="permission" class="form-control" title="permission" required></select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button type="submit" class="btn btn-primary">add</button>
</div>
</form>
</div>
</div>
<div class="container">
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
<ul class="nav">
<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 class="nav">
<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/issues" title="issues tracker">report a bug</a></li>
</ul>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script>
<script src="https://unpkg.com/tableexport.jquery.plugin/tableExport.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://unpkg.com/bootstrap-table@1.20.2/dist/bootstrap-table.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.20.2/dist/extensions/resizable/bootstrap-table-resizable.js"></script>
<script src="/static/utils.js"></script>
<script src="/static/load.js"></script>
<script>
const [partyId, isReadOnly] = getPartyId();
const table = $("#users");
const addButton = $("#add-btn");
const removeButton = $("#remove-btn");
const addUserDialog = $("#add-user-dialog");
const usernameInput = $("#username");
const passwordInput = $("#password");
const permissionInput = $("#permission");
function addUser() {
$.ajax({
url: `/api/v1/party/${partyId}/users`,
data: JSON.stringify({
partyId: partyId,
username: usernameInput.val(),
password: passwordInput.val(),
permission: permissionInput.val(),
}),
type: "POST",
contentType: "application/json",
success: _ => { reload(); },
error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
});
addUserDialog.modal("hide");
return true; // action expects boolean result
}
function hideControls() {
addButton.attr("hidden", isReadOnly);
removeButton.attr("hidden", isReadOnly);
}
function reload() {
table.bootstrapTable("showLoading");
$.ajax({
url: `/api/v1/party/${partyId}/users`,
type: "GET",
dataType: "json",
success: response => {
table.bootstrapTable("load", response);
table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading");
},
error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
});
}
function removeUsers() {
const users = table.bootstrapTable("getSelections");
users.map(user => {
$.ajax({
url: `/api/v1/party/${partyId}/users/${user.username}`,
type: "DELETE",
success: _ => { reload(); },
error: (jqXHR, _, errorThrown) => { requestAlert(jqXHR, errorThrown); },
});
});
}
$(() => {
setupFormClear(addUserDialog);
setupRemoveButton(table, removeButton);
loadVersion();
loadHeader(partyId);
loadTypes("/api/v1/types/permissions", permissionInput);
hideControls();
table.bootstrapTable({});
reload();
});
</script>
</body>
</html>

View File

@ -3,15 +3,15 @@
<include resource="logback-application.xml" />
<include resource="logback-http.xml" />
<root level="debug">
<root level="DEBUG">
<appender-ref ref="application" />
</root>
<logger name="me.arcanis.ffxivbis" level="DEBUG" />
<logger name="http" level="DEBUG" additivity="false">
<appender-ref ref="http" />
</logger>
<logger name="slick" 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>

View File

@ -15,26 +15,17 @@ me.arcanis.ffxivbis {
mode = "sqlite"
sqlite {
profile = "slick.jdbc.SQLiteProfile$"
db {
url = "jdbc:sqlite:ffxivbis.db"
user = "user"
password = "password"
}
numThreads = 10
driverClassName = "org.sqlite.JDBC"
jdbcUrl = "jdbc:sqlite:ffxivbis.db"
#username = "user"
#password = "password"
}
postgresql {
profile = "slick.jdbc.PostgresProfile$"
db {
url = "jdbc:postgresql://localhost/ffxivbis"
user = "ffxivbis"
password = "ffxivbis"
connectionPool = disabled
keepAliveConnection = yes
}
numThreads = 10
driverClassName = "org.postgresql.Driver"
jdbcUrl = "jdbc:postgresql://localhost/ffxivbis"
#username = "ffxivbis"
#password = "ffxivbis"
}
}
@ -57,6 +48,16 @@ me.arcanis.ffxivbis {
port = 8000
# hostname to use in docs, if not set host:port will be used
#hostname = "127.0.0.1:8000"
# enable head requests for GET requests
enable-head-requests = yes
schemes = ["http"]
authorization-cache {
# maximum amount of cached logins
cache-size = 1024
# ttl of cached logins
cache-timeout = 1m
}
}
default-dispatcher {

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -0,0 +1,64 @@
function loadHeader(partyId) {
const title = $("#navbar-title");
// because I don't know how to handle relative url if current does not end with slash
title.attr("href", `/party/${partyId}`);
$("#navbar-bis").attr("href", `/party/${partyId}/bis`);
$("#navbar-loot").attr("href", `/party/${partyId}/loot`);
$("#navbar-users").attr("href", `/party/${partyId}/users`);
$.ajax({
url: `/api/v1/party/${partyId}/description`,
type: "GET",
dataType: "json",
success: function (resp) {
title.text(safe(resp.partyAlias || partyId));
},
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
function loadTypes(url, selector) {
$.ajax({
url: url,
type: "GET",
dataType: "json",
success: function (data) {
const options = data.map(function (name) {
const option = document.createElement("option");
option.value = name;
option.innerText = name;
return option;
});
selector.empty().append(options);
},
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
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) {
dialog.on("hide.bs.modal", function () {
$(this).find("form").trigger("reset");
$(this).find("table").bootstrapTable("removeAll");
if (reset) {
reset();
}
});
}
function setupRemoveButton(table, removeButton) {
table.on("check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table",
function () {
removeButton.prop("disabled", !table.bootstrapTable("getSelections").length);
});
}

View File

@ -1,277 +0,0 @@
/* in-text images */
figure.img {
float: right;
border: 0px solid #333;
padding: 0px;
margin: 5px 0px 5px 10px;
}
figure.img img {
max-width: 100%;
height: auto;
}
figure.img figcaption {
margin: 0px;
font-size: 90%;
font-style: italic;
text-align: center;
}
h1 .octicon-link, h2 .octicon-link, h3 .octicon-link, h4 .octicon-link, h5 .octicon-link, h6 .octicon-link {
display: none;
color: #222222;
vertical-align: middle;
}
h1:hover .anchor, h2:hover .anchor, h3:hover .anchor, h4:hover .anchor, h5:hover .anchor, h6:hover .anchor{
padding-left: 8px;
margin-left: -24px;
text-decoration: none;
}
h1:hover .anchor .octicon-link, h2:hover .anchor .octicon-link, h3:hover .anchor .octicon-link, h4:hover .anchor .octicon-link, h5:hover .anchor .octicon-link, h6:hover .anchor .octicon-link {
display: inline-block;
}
body {
padding: 50px;
font: 14px/1.5 "Liberation Sans", Helvetica, Arial, sans-serif;
color: #555555;
background: #eaeaea
}
h1, h2, h3, h4, h5, h6 {
color: #222222;
margin: 0 0 20px;
}
p, ul, ol, table, pre, dl {
margin: 0 0 20px;
text-align: justify;
}
h1, h2, h3 {
line-height: 1.1;
}
h1 {
font-size: 28px;
}
h2 {
color: #393939;
}
h3, h4, h5, h6 {
color: #494949;
}
a {
color: #3399cc;
font-weight: 350;
text-decoration: none;
}
a small {
font-size: 11px;
color: #777777;
margin-top: -0.6em;
display: block;
}
.wrapper {
width: 80%;
margin: 0 auto;
}
blockquote {
border-left: 1px solid #ffffff;
margin: 0;
padding: 0 0 0 20px;
font-style: italic;
}
code, pre {
font-family: "Liberation Mono", Monaco, Bitstream Vera Sans Mono, Lucida Console, Terminal;
color: #222222;
font-size: 12px;
}
pre {
padding: 8px 15px;
border-radius: 5px;
border: 1px solid #e5e5e5;
overflow-x: auto;
overflow-y: auto;
}
input, select{
box-sizing: border-box;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 5px 10px;
border-bottom: 1px solid #ffffff;
}
td {
text-align: justify;
}
dt {
color: #444444;
font-weight: 700;
}
th {
text-align: left;
color: #444444;
}
img {
max-width: 100%;
}
header {
width: 20%;
float: left;
position: fixed;
}
header ul {
list-style: none;
height: 40px;
padding: 0;
background: #eeeeee;
border-radius: 5px;
border: 1px solid #d2d2d2;
box-shadow: inset #fff 0 1px 0, inset rgba(0,0,0,0.03) 0 -1px 0;
width: 15%;
}
header li {
width: 8%;
float: left;
border-right: 1px solid #d2d2d2;
height: 40px;
}
header ul a {
line-height: 1;
font-size: 11px;
color: #999999;
display: block;
text-align: center;
padding-top: 6px;
height: 40px;
}
strong {
color: #222222;
font-weight: 700;
}
header ul li + li {
width: 8%;
border-left: 1px solid #ffffff;
}
header ul li + li + li {
width: 8%;
border-right: none;
}
header ul a strong {
font-size: 14px;
display: block;
color: #222222;
}
section {
width: 70%;
float: right;
padding-bottom: 50px;
}
small {
font-size: 11px;
}
hr {
border: 0;
background: #ffffff;
height: 1px;
margin: 0 0 20px;
}
footer {
width: 20%;
float: left;
position: fixed;
bottom: 50px;
}
@media print, screen and (max-width: 960px) {
div.wrapper {
width: auto;
margin: 0;
}
header, section, footer {
float: none;
position: static;
width: auto;
}
header {
padding-right: 320px;
}
section {
border: 1px solid #e5e5e5;
border-width: 1px 0;
padding: 20px 0;
margin: 0 0 20px;
}
header a small {
display: inline;
}
header ul {
position: absolute;
right: 50px;
top: 52px;
}
}
@media print, screen and (max-width: 720px) {
body {
word-wrap: break-word;
}
header {
padding: 0;
}
header ul, header p.view {
position: static;
}
pre, code {
word-wrap: normal;
}
}
@media print, screen and (max-width: 480px) {
body {
padding: 15px;
}
header ul {
display: none;
}
}
@media print {
body {
padding: 0.4in;
font-size: 12pt;
color: #444444;
}
}

View File

@ -1,31 +0,0 @@
function downloadCsv(csv, filename) {
var csvFile = new Blob([csv], {"type": "text/csv"});
var downloadLink = document.createElement("a");
downloadLink.download = filename;
downloadLink.href = window.URL.createObjectURL(csvFile);
downloadLink.style.display = "none";
document.body.appendChild(downloadLink);
downloadLink.click();
}
function exportTableToCsv(filename) {
var table = document.getElementById("result");
var rows = table.getElementsByTagName("tr");
var csv = [];
for (var i = 0; i < rows.length; i++) {
if (rows[i].style.display === "none")
continue;
var cols = rows[i].querySelectorAll("td, th");
var row = [];
for (var j = 0; j < cols.length; j++)
row.push(cols[j].innerText);
csv.push(row.join(","));
}
downloadCsv(csv.join("\n"), filename);
}

View File

@ -1,21 +0,0 @@
function searchTable() {
var input = document.getElementById("search");
var filter = input.value.toLowerCase();
var table = document.getElementById("result");
var tr = table.getElementsByTagName("tr");
// from 1 coz of header
for (var i = 1; i < tr.length; i++) {
var td = tr[i].getElementsByClassName("include_search");
var display = "none";
for (var j = 0; j < td.length; j++) {
if (td[j].tagName.toLowerCase() === "td") {
if (td[j].innerHTML.toLowerCase().indexOf(filter) > -1) {
display = "";
break;
}
}
}
tr[i].style.display = display;
}
}

View File

@ -0,0 +1,44 @@
function createAlert(message, placeholder) {
const wrapper = document.createElement('div');
wrapper.innerHTML = `<div class="alert alert-danger alert-dismissible" role="alert">${safe(message)}<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button></div>`;
placeholder.append(wrapper);
}
function formatPlayerId(obj) {
return `${obj.nick} (${obj.job})`;
}
function getCurrentOption(select) {
return select.find(":selected")[0];
}
function getPartyId() {
const request = new XMLHttpRequest();
request.open("HEAD", document.location, false);
request.send(null);
// tuple lol
return [
request.getResponseHeader("X-Party-Id"),
request.getResponseHeader("X-User-Permission") === "get",
]
}
function requestAlert(jqXHR, errorThrown) {
let message;
try {
message = $.parseJSON(jqXHR.responseText).message;
} catch (_) {
message = errorThrown;
}
const alert = $("#alert-placeholder");
createAlert(`Error during request: ${message}`, alert);
}
function safe(string) {
return String(string)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

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
@ -11,6 +11,8 @@ REST json API description to interact with FFXIVBiS service.
# Limitations
No limitations for the API so far.
# Authentication
For the most party utils service requires user to be authenticated. User permission can be one of `get`, `post` or `admin`.

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -8,17 +8,16 @@
*/
package me.arcanis.ffxivbis
import akka.actor.typed.{Behavior, PostStop, Signal}
import akka.actor.typed.scaladsl.{AbstractBehavior, ActorContext, Behaviors}
import akka.actor.typed.{Behavior, PostStop, Signal}
import akka.http.scaladsl.Http
import akka.http.scaladsl.server.Route
import akka.stream.Materializer
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.http.RootEndpoint
import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.database.Database
import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.database.{Database, Migration}
import scala.concurrent.ExecutionContext
import scala.jdk.CollectionConverters._
@ -51,7 +50,7 @@ class Application(context: ActorContext[Nothing]) extends AbstractBehavior[Nothi
val party = context.spawn(PartyService(storage), "party")
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)
case Success(result) =>

View File

@ -0,0 +1,23 @@
/*
* 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
import com.typesafe.config.{Config, ConfigFactory, ConfigValueFactory}
object Configuration {
def load(): Config = {
val root = ConfigFactory.load()
root
.withValue(
"akka.http.server.transparent-head-requests",
ConfigValueFactory.fromAnyRef(root.getBoolean("me.arcanis.ffxivbis.web.enable-head-requests"))
)
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -9,12 +9,8 @@
package me.arcanis.ffxivbis
import akka.actor.typed.ActorSystem
import com.typesafe.config.ConfigFactory
object ffxivbis {
object ffxivbis extends App {
def main(args: Array[String]): Unit = {
val config = ConfigFactory.load()
ActorSystem[Nothing](Application(), "ffxivbis", config)
}
ActorSystem[Nothing](Application(), "ffxivbis", Configuration.load())
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -8,22 +8,18 @@
*/
package me.arcanis.ffxivbis.http
import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.headers._
import akka.http.scaladsl.server.AuthenticationFailedRejection._
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import me.arcanis.ffxivbis.messages.{GetUser, Message}
import me.arcanis.ffxivbis.models.Permission
import me.arcanis.ffxivbis.models.{Permission, User}
import scala.concurrent.{ExecutionContext, Future}
// idea comes from https://synkre.com/bcrypt-for-akka-http-password-encryption/
trait Authorization {
def storage: ActorRef[Message]
def auth: AuthorizationProvider
def authenticateBasicBCrypt[T](realm: String, authenticate: (String, String) => Future[Option[T]]): Directive1[T] = {
def challenge = HttpChallenges.basic(realm)
@ -38,34 +34,19 @@ trait Authorization {
}
}
def authenticator(scope: Permission.Value, partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext,
timeout: Timeout,
scheduler: Scheduler
): Future[Option[String]] =
storage.ask(GetUser(partyId, username, _)).map {
case Some(user) if user.verify(password) && user.verityScope(scope) => Some(username)
case _ => None
}
def authAdmin(partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext,
timeout: Timeout,
scheduler: Scheduler
): Future[Option[String]] =
authenticator(Permission.admin, partyId)(username, password)
executionContext: ExecutionContext
): Future[Option[User]] =
auth.authenticator(Permission.admin, partyId)(username, password)
def authGet(partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext,
timeout: Timeout,
scheduler: Scheduler
): Future[Option[String]] =
authenticator(Permission.get, partyId)(username, password)
executionContext: ExecutionContext
): Future[Option[User]] =
auth.authenticator(Permission.get, partyId)(username, password)
def authPost(partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext,
timeout: Timeout,
scheduler: Scheduler
): Future[Option[String]] =
authenticator(Permission.post, partyId)(username, password)
executionContext: ExecutionContext
): Future[Option[User]] =
auth.authenticator(Permission.post, partyId)(username, password)
}

View File

@ -0,0 +1,64 @@
/*
* 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
import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.actor.typed.{ActorRef, Scheduler}
import akka.util.Timeout
import com.google.common.cache.{CacheBuilder, CacheLoader, LoadingCache}
import com.typesafe.config.Config
import me.arcanis.ffxivbis.messages.DatabaseMessage.GetUser
import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.{Permission, User}
import java.util.concurrent.TimeUnit
import scala.concurrent.{ExecutionContext, Future}
trait AuthorizationProvider {
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 {
def apply(config: Config, storage: ActorRef[Message])(implicit
timeout: Timeout,
scheduler: Scheduler
): AuthorizationProvider =
new AuthorizationProvider {
private val cacheSize = config.getInt("me.arcanis.ffxivbis.web.authorization-cache.cache-size")
private val cacheTimeout =
config.getDuration("me.arcanis.ffxivbis.web.authorization-cache.cache-timeout", TimeUnit.MILLISECONDS)
private val cache: LoadingCache[(String, String), Future[Option[User]]] = CacheBuilder
.newBuilder()
.expireAfterWrite(cacheTimeout, TimeUnit.MILLISECONDS)
.maximumSize(cacheSize)
.build(
new CacheLoader[(String, String), Future[Option[User]]] {
override def load(key: (String, String)): Future[Option[User]] = {
val (partyId, username) = key
storage.ask(GetUser(partyId, username, _))(timeout, scheduler)
}
}
)
override def get(partyId: String, username: String): Future[Option[User]] =
cache.get((partyId, username))
}
}

View File

@ -0,0 +1,74 @@
/*
* 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
import akka.http.scaladsl.model.headers.{`User-Agent`, Authorization, BasicHttpCredentials, Referer}
import akka.http.scaladsl.server.Directive0
import akka.http.scaladsl.server.Directives.{extractClientIP, extractRequestContext, mapResponse, optionalHeaderValueByType}
import com.typesafe.scalalogging.Logger
import java.time.format.DateTimeFormatter
import java.time.{Instant, ZoneId}
import java.util.Locale
trait HttpLog {
private val httpLogger = Logger("http")
def withHttpLog: Directive0 =
extractRequestContext.flatMap { context =>
val request = s"${context.request.method.name()} ${context.request.uri.path}"
extractClientIP.flatMap { maybeRemoteAddr =>
val remoteAddr = maybeRemoteAddr.toIP.getOrElse("-")
optionalHeaderValueByType(Referer).flatMap { maybeReferer =>
val referer = maybeReferer.map(_.uri).getOrElse("-")
optionalHeaderValueByType(`User-Agent`).flatMap { maybeUserAgent =>
val userAgent = maybeUserAgent.map(_.products.map(_.toString()).mkString(" ")).getOrElse("-")
optionalHeaderValueByType(Authorization).flatMap { maybeAuth =>
val remoteUser = maybeAuth
.map(_.credentials)
.collect { case BasicHttpCredentials(username, _) =>
username
}
.getOrElse("-")
val start = Instant.now.toEpochMilli
val timeLocal = HttpLog.httpLogDatetimeFormatter.format(Instant.now)
mapResponse { response =>
val time = (Instant.now.toEpochMilli - start) / 1000.0
val status = response.status.intValue()
val bytesSent = response.entity.getContentLengthOption.getAsLong
httpLogger.debug(
s"""$remoteAddr - $remoteUser [$timeLocal] "$request" $status $bytesSent "$referer" "$userAgent" $time"""
)
response
}
}
}
}
}
}
}
object HttpLog {
val httpLogDatetimeFormatter: DateTimeFormatter =
DateTimeFormatter
.ofPattern("dd/MMM/uuuu:HH:mm:ss xx")
.withLocale(Locale.UK)
.withZone(ZoneId.systemDefault())
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -8,67 +8,56 @@
*/
package me.arcanis.ffxivbis.http
import java.time.Instant
import akka.actor.typed.{ActorRef, ActorSystem, Scheduler}
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import com.typesafe.scalalogging.{Logger, StrictLogging}
import ch.megard.akka.http.cors.scaladsl.CorsDirectives.cors
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.http.api.v1.RootApiV1Endpoint
import me.arcanis.ffxivbis.http.view.RootView
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
class RootEndpoint(system: ActorSystem[Nothing], storage: ActorRef[Message], provider: ActorRef[BiSProviderMessage])
extends StrictLogging {
extends StrictLogging
with HttpLog {
import me.arcanis.ffxivbis.utils.Implicits._
private val config = system.settings.config
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 rootApiV1Endpoint: RootApiV1Endpoint = new RootApiV1Endpoint(storage, provider, config)
private val rootView: RootView = new RootView(storage, provider)
private val swagger: Swagger = new Swagger(config)
private val httpLogger = Logger("http")
private val auth = AuthorizationProvider(config, storage)
private val withHttpLog: Directive0 =
extractRequestContext.flatMap { context =>
val start = Instant.now.toEpochMilli
mapResponse { response =>
val time = (Instant.now.toEpochMilli - start) / 1000.0
httpLogger.debug(
s"""- - [${Instant.now}] "${context.request.method.name()} ${context.request.uri.path}" ${response.status
.intValue()} ${response.entity.getContentLengthOption.getAsLong} $time"""
)
response
}
}
private val rootApiV1Endpoint = new RootApiV1Endpoint(storage, auth, provider, config)
private val rootView = new RootView(auth)
private val swagger = new Swagger(config)
def route: Route =
def routes: Route =
withHttpLog {
ignoreTrailingSlash {
apiRoute ~ htmlRoute ~ swagger.routes ~ swaggerUIRoute
cors() {
apiRoutes ~ htmlRoutes ~ swagger.routes ~ swaggerUIRoutes
}
}
}
private def apiRoute: Route =
private def apiRoutes: Route =
pathPrefix("api") {
pathPrefix(Segment) {
case "v1" => rootApiV1Endpoint.route
case "v1" => rootApiV1Endpoint.routes
case _ => reject
}
}
private def htmlRoute: Route =
private def htmlRoutes: Route =
pathPrefix("static") {
getFromResourceDirectory("static")
} ~ rootView.route
} ~ rootView.routes
private def swaggerUIRoute: Route =
path("swagger") {
getFromResource("html/swagger.html")
private def swaggerUIRoutes: Route =
path("api-docs") {
getFromResource("html/api.html")
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -14,6 +14,7 @@ import com.typesafe.config.Config
import io.swagger.v3.oas.models.security.SecurityScheme
import scala.io.Source
import scala.jdk.CollectionConverters._
class Swagger(config: Config) extends SwaggerHttpService {
@ -22,6 +23,7 @@ class Swagger(config: Config) extends SwaggerHttpService {
classOf[api.v1.LootEndpoint],
classOf[api.v1.PartyEndpoint],
classOf[api.v1.PlayerEndpoint],
classOf[api.v1.StatusEndpoint],
classOf[api.v1.TypesEndpoint],
classOf[api.v1.UserEndpoint]
)
@ -35,15 +37,17 @@ class Swagger(config: Config) extends SwaggerHttpService {
override val host: String =
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()
.description("basic http auth")
.`type`(SecurityScheme.Type.HTTP)
.in(SecurityScheme.In.HEADER)
.scheme("bearer")
override val securitySchemes: Map[String, SecurityScheme] = Map("basic auth" -> basicAuth)
.scheme("basic")
override val securitySchemes: Map[String, SecurityScheme] = Map("basic" -> basicAuth)
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

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -21,21 +21,26 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import jakarta.ws.rs._
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.http.{Authorization, BiSHelper}
import me.arcanis.ffxivbis.http.helpers.BiSHelper
import me.arcanis.ffxivbis.http.{Authorization, AuthorizationProvider}
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
import me.arcanis.ffxivbis.models.PlayerId
import scala.util.{Failure, Success}
@Path("/api/v1")
class BiSEndpoint(override val storage: ActorRef[Message], override val provider: ActorRef[BiSProviderMessage])(implicit
class BiSEndpoint(
override val storage: ActorRef[Message],
override val provider: ActorRef[BiSProviderMessage],
override val auth: AuthorizationProvider
)(implicit
timeout: Timeout,
scheduler: Scheduler
) extends BiSHelper
with Authorization
with JsonSupport {
def route: Route = createBiS ~ getBiS ~ modifyBiS
def routes: Route = createBiS ~ getBiS ~ modifyBiS
@PUT
@Path("party/{partyId}/bis")
@ -44,37 +49,42 @@ class BiSEndpoint(override val storage: ActorRef[Message], override val provider
summary = "create best in slot",
description = "Create the best in slot set",
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(
description = "player best in slot description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PlayerBiSLinkResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[PlayerBiSLinkModel])))
),
responses = Array(
new ApiResponse(responseCode = "201", description = "Best in slot set has been created"),
new ApiResponse(
responseCode = "400",
description = "Invalid parameters were supplied",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
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"),
)
def createBiS: Route =
@ -82,11 +92,10 @@ class BiSEndpoint(override val storage: ActorRef[Message], override val provider
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
put {
entity(as[PlayerBiSLinkResponse]) { bisLink =>
entity(as[PlayerBiSLinkModel]) { bisLink =>
val playerId = bisLink.playerId.withPartyId(partyId)
onComplete(putBiS(playerId, bisLink.link)) {
case Success(_) => complete(StatusCodes.Created, HttpEntity.Empty)
case Failure(exception) => throw exception
onSuccess(putBiS(playerId, bisLink.link)) {
complete(StatusCodes.Created, HttpEntity.Empty)
}
}
}
@ -101,7 +110,12 @@ class BiSEndpoint(override val storage: ActorRef[Message], override val provider
summary = "get best in slot",
description = "Return the best in slot items",
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 = "nick",
in = ParameterIn.QUERY,
@ -116,27 +130,27 @@ class BiSEndpoint(override val storage: ActorRef[Message], override val provider
description = "Best in slot",
content = Array(
new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse]))
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerModel]))
)
)
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
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"),
)
def getBiS: Route =
@ -146,9 +160,8 @@ class BiSEndpoint(override val storage: ActorRef[Message], override val provider
get {
parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) =>
val playerId = PlayerId(partyId, maybeNick, maybeJob)
onComplete(bis(partyId, playerId)) {
case Success(response) => complete(response.map(PlayerResponse.fromPlayer))
case Failure(exception) => throw exception
onSuccess(bis(partyId, playerId)) { response =>
complete(response.map(PlayerModel.fromPlayer))
}
}
}
@ -164,37 +177,42 @@ class BiSEndpoint(override val storage: ActorRef[Message], override val provider
summary = "modify best in slot",
description = "Add or remove an item from the best in slot",
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(
description = "action and piece description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PieceActionResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[PieceActionModel])))
),
responses = Array(
new ApiResponse(responseCode = "202", description = "Best in slot set has been modified"),
new ApiResponse(
responseCode = "400",
description = "Invalid parameters were supplied",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
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"),
)
def modifyBiS: Route =
@ -202,11 +220,10 @@ class BiSEndpoint(override val storage: ActorRef[Message], override val provider
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
entity(as[PieceActionResponse]) { action =>
entity(as[PieceActionModel]) { action =>
val playerId = action.playerId.withPartyId(partyId)
onComplete(doModifyBiS(action.action, playerId, action.piece.toPiece)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception
onSuccess(doModifyBiS(action.action, playerId, action.piece.toPiece)) {
complete(StatusCodes.Accepted, HttpEntity.Empty)
}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -11,26 +11,46 @@ package me.arcanis.ffxivbis.http.api.v1
import akka.http.scaladsl.model._
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import ch.megard.akka.http.cors.scaladsl.CorsDirectives.corsRejectionHandler
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.http.api.v1.json._
import spray.json._
import scala.util.control.NonFatal
trait HttpHandler extends StrictLogging { this: JsonSupport =>
def exceptionHandler: ExceptionHandler = ExceptionHandler {
case ex: IllegalArgumentException =>
complete(StatusCodes.BadRequest, ErrorResponse(ex.getMessage))
case exception: IllegalArgumentException =>
complete(StatusCodes.BadRequest, ErrorModel(exception.getMessage))
case other: Exception =>
case NonFatal(other) =>
logger.error("exception during request completion", other)
complete(StatusCodes.InternalServerError, ErrorResponse("unknown server error"))
complete(StatusCodes.InternalServerError, ErrorModel("unknown server error"))
}
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 {
case response @ HttpResponse(_, _, entity: HttpEntity.Strict, _) =>
val message = ErrorResponse(entity.data.utf8String).toJson
case response @ HttpResponse(_, _, entity: HttpEntity.Strict, _) if entity.data.nonEmpty =>
val message = ErrorModel(entity.data.utf8String).toJson
response.withEntity(HttpEntity(ContentTypes.`application/json`, message.compactPrint))
case other => other
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -21,20 +21,23 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import jakarta.ws.rs._
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.http.{Authorization, LootHelper}
import me.arcanis.ffxivbis.http.helpers.LootHelper
import me.arcanis.ffxivbis.http.{Authorization, AuthorizationProvider}
import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.PlayerId
import scala.util.{Failure, Success}
@Path("/api/v1")
class LootEndpoint(override val storage: ActorRef[Message])(implicit timeout: Timeout, scheduler: Scheduler)
extends LootHelper
class LootEndpoint(override val storage: ActorRef[Message], override val auth: AuthorizationProvider)(implicit
timeout: Timeout,
scheduler: Scheduler
) extends LootHelper
with Authorization
with JsonSupport
with HttpHandler {
def route: Route = getLoot ~ modifyLoot
def routes: Route = getLoot ~ modifyLoot ~ suggestLoot
@GET
@Path("party/{partyId}/loot")
@ -43,7 +46,12 @@ class LootEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
summary = "get loot list",
description = "Return the looted items",
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 = "nick",
in = ParameterIn.QUERY,
@ -58,27 +66,27 @@ class LootEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
description = "Loot list",
content = Array(
new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse]))
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerModel]))
)
)
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
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"),
)
def getLoot: Route =
@ -88,9 +96,8 @@ class LootEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
get {
parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) =>
val playerId = PlayerId(partyId, maybeNick, maybeJob)
onComplete(loot(partyId, playerId)) {
case Success(response) => complete(response.map(PlayerResponse.fromPlayer))
case Failure(exception) => throw exception
onSuccess(loot(partyId, playerId)) { response =>
complete(response.map(PlayerModel.fromPlayer))
}
}
}
@ -105,37 +112,42 @@ class LootEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
summary = "modify loot list",
description = "Add or remove an item from the loot list",
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(
description = "action and piece description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PieceActionResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[PieceActionModel])))
),
responses = Array(
new ApiResponse(responseCode = "202", description = "Loot list has been modified"),
new ApiResponse(
responseCode = "400",
description = "Invalid parameters were supplied",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
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"),
)
def modifyLoot: Route =
@ -143,11 +155,10 @@ class LootEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
entity(as[PieceActionResponse]) { action =>
entity(as[PieceActionModel]) { action =>
val playerId = action.playerId.withPartyId(partyId)
onComplete(doModifyLoot(action.action, playerId, action.piece.toPiece, action.isFreeLoot)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception
onSuccess(doModifyLoot(action.action, playerId, action.piece.toPiece, action.isFreeLoot)) {
complete(StatusCodes.Accepted, HttpEntity.Empty)
}
}
}
@ -163,12 +174,17 @@ class LootEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
summary = "suggest loot",
description = "Suggest loot piece to party",
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(
description = "piece description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PieceResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[PieceModel])))
),
responses = Array(
new ApiResponse(
@ -176,32 +192,32 @@ class LootEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
description = "Players with counters ordered by priority to get this item",
content = Array(
new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerIdWithCountersResponse])),
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerIdWithCountersModel])),
)
)
),
new ApiResponse(
responseCode = "400",
description = "Invalid parameters were supplied",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
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"),
)
def suggestLoot: Route =
@ -209,10 +225,9 @@ class LootEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
put {
entity(as[PieceResponse]) { piece =>
onComplete(suggestPiece(partyId, piece.toPiece)) {
case Success(response) => complete(response.map(PlayerIdWithCountersResponse.fromPlayerId))
case Failure(exception) => throw exception
entity(as[PieceModel]) { piece =>
onSuccess(suggestPiece(partyId, piece.toPiece)) { response =>
complete(response.map(PlayerIdWithCountersModel.fromPlayerId))
}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2020 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -21,14 +21,18 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import jakarta.ws.rs._
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.http.{Authorization, PlayerHelper}
import me.arcanis.ffxivbis.http.helpers.PlayerHelper
import me.arcanis.ffxivbis.http.{Authorization, AuthorizationProvider}
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
import scala.util.{Failure, Success}
@Path("/api/v1")
class PartyEndpoint(override val storage: ActorRef[Message], override val provider: ActorRef[BiSProviderMessage])(
implicit
class PartyEndpoint(
override val storage: ActorRef[Message],
override val provider: ActorRef[BiSProviderMessage],
override val auth: AuthorizationProvider
)(implicit
timeout: Timeout,
scheduler: Scheduler
) extends PlayerHelper
@ -36,7 +40,7 @@ class PartyEndpoint(override val storage: ActorRef[Message], override val provid
with JsonSupport
with HttpHandler {
def route: Route = getPartyDescription ~ modifyPartyDescription
def routes: Route = getPartyDescription ~ modifyPartyDescription
@GET
@Path("party/{partyId}/description")
@ -45,31 +49,36 @@ class PartyEndpoint(override val storage: ActorRef[Message], override val provid
summary = "get party description",
description = "Return the party description",
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(
new ApiResponse(
responseCode = "200",
description = "Party description",
content = Array(new Content(schema = new Schema(implementation = classOf[PartyDescriptionResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[PartyDescriptionModel])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
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"),
)
def getPartyDescription: Route =
@ -77,9 +86,8 @@ class PartyEndpoint(override val storage: ActorRef[Message], override val provid
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
onComplete(getPartyDescription(partyId)) {
case Success(response) => complete(PartyDescriptionResponse.fromDescription(response))
case Failure(exception) => throw exception
onSuccess(getPartyDescription(partyId)) { response =>
complete(PartyDescriptionModel.fromDescription(response))
}
}
}
@ -93,37 +101,42 @@ class PartyEndpoint(override val storage: ActorRef[Message], override val provid
summary = "modify party description",
description = "Edit party description",
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(
description = "new party description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PartyDescriptionResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[PartyDescriptionModel])))
),
responses = Array(
new ApiResponse(responseCode = "202", description = "Party description has been modified"),
new ApiResponse(
responseCode = "400",
description = "Invalid parameters were supplied",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
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"),
)
def modifyPartyDescription: Route =
@ -131,11 +144,10 @@ class PartyEndpoint(override val storage: ActorRef[Message], override val provid
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
entity(as[PartyDescriptionResponse]) { partyDescription =>
entity(as[PartyDescriptionModel]) { partyDescription =>
val description = partyDescription.copy(partyId = partyId)
onComplete(updateDescription(description.toDescription)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception
onSuccess(updateDescription(description.toDescription)) {
complete(StatusCodes.Accepted, HttpEntity.Empty)
}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -21,15 +21,19 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import jakarta.ws.rs._
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.http.{Authorization, PlayerHelper}
import me.arcanis.ffxivbis.http.helpers.PlayerHelper
import me.arcanis.ffxivbis.http.{Authorization, AuthorizationProvider}
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
import me.arcanis.ffxivbis.models.PlayerId
import scala.util.{Failure, Success}
@Path("/api/v1")
class PlayerEndpoint(override val storage: ActorRef[Message], override val provider: ActorRef[BiSProviderMessage])(
implicit
class PlayerEndpoint(
override val storage: ActorRef[Message],
override val provider: ActorRef[BiSProviderMessage],
override val auth: AuthorizationProvider
)(implicit
timeout: Timeout,
scheduler: Scheduler
) extends PlayerHelper
@ -37,7 +41,7 @@ class PlayerEndpoint(override val storage: ActorRef[Message], override val provi
with JsonSupport
with HttpHandler {
def route: Route = getParty ~ modifyParty
def routes: Route = getParty ~ getPartyStats ~ modifyParty
@GET
@Path("party/{partyId}")
@ -46,7 +50,12 @@ class PlayerEndpoint(override val storage: ActorRef[Message], override val provi
summary = "get party",
description = "Return the players who belong to the party",
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 = "nick",
in = ParameterIn.QUERY,
@ -61,27 +70,27 @@ class PlayerEndpoint(override val storage: ActorRef[Message], override val provi
description = "Players list",
content = Array(
new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse])),
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerModel])),
)
)
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
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"),
)
def getParty: Route =
@ -91,9 +100,74 @@ class PlayerEndpoint(override val storage: ActorRef[Message], override val provi
get {
parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) =>
val playerId = PlayerId(partyId, maybeNick, maybeJob)
onComplete(getPlayers(partyId, playerId)) {
case Success(response) => complete(response.map(PlayerResponse.fromPlayer))
case Failure(exception) => throw exception
onSuccess(getPlayers(partyId, playerId)) { response =>
complete(response.map(PlayerModel.fromPlayer))
}
}
}
}
}
}
@GET
@Path("party/{partyId}/stats")
@Produces(value = Array("application/json"))
@Operation(
summary = "get party statistics",
description = "Return the party statistics",
parameters = Array(
new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
new Parameter(
name = "nick",
in = ParameterIn.QUERY,
description = "player nick name to filter",
example = "Siuan Sanche"
),
new Parameter(name = "job", in = ParameterIn.QUERY, description = "player job to filter", example = "DNC"),
),
responses = Array(
new ApiResponse(
responseCode = "200",
description = "Party loot statistics",
content = Array(
new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerIdWithCountersModel])),
)
)
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
),
security = Array(new SecurityRequirement(name = "basic", scopes = Array("get"))),
tags = Array("party"),
)
def getPartyStats: Route =
path("party" / Segment / "stats") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) =>
val playerId = PlayerId(partyId, maybeNick, maybeJob)
onSuccess(getPlayers(partyId, playerId)) { response =>
complete(response.map(player => PlayerIdWithCountersModel.fromPlayerId(player.withCounters(None))))
}
}
}
@ -108,48 +182,54 @@ class PlayerEndpoint(override val storage: ActorRef[Message], override val provi
summary = "modify party",
description = "Add or remove a player from party list",
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(
description = "player description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PlayerActionResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[PlayerActionModel])))
),
responses = Array(
new ApiResponse(responseCode = "202", description = "Party has been modified"),
new ApiResponse(
responseCode = "400",
description = "Invalid parameters were supplied",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
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"),
)
def modifyParty: Route =
path("party" / Segment) { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
entity(as[PlayerActionResponse]) { action =>
post {
entity(as[PlayerActionModel]) { action =>
val player = action.playerId.toPlayer.copy(partyId = partyId)
onComplete(doModifyPlayer(action.action, player)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception
onSuccess(doModifyPlayer(action.action, player)) {
complete(StatusCodes.Accepted, HttpEntity.Empty)
}
}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -13,27 +13,34 @@ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.util.Timeout
import com.typesafe.config.Config
import me.arcanis.ffxivbis.http.AuthorizationProvider
import me.arcanis.ffxivbis.http.api.v1.json.JsonSupport
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
class RootApiV1Endpoint(storage: ActorRef[Message], provider: ActorRef[BiSProviderMessage], config: Config)(implicit
class RootApiV1Endpoint(
storage: ActorRef[Message],
auth: AuthorizationProvider,
provider: ActorRef[BiSProviderMessage],
config: Config
)(implicit
timeout: Timeout,
scheduler: Scheduler
) extends JsonSupport
with HttpHandler {
private val biSEndpoint = new BiSEndpoint(storage, provider)
private val lootEndpoint = new LootEndpoint(storage)
private val partyEndpoint = new PartyEndpoint(storage, provider)
private val playerEndpoint = new PlayerEndpoint(storage, provider)
private val biSEndpoint = new BiSEndpoint(storage, provider, auth)
private val lootEndpoint = new LootEndpoint(storage, auth)
private val partyEndpoint = new PartyEndpoint(storage, provider, auth)
private val playerEndpoint = new PlayerEndpoint(storage, provider, auth)
private val statusEndpoint = new StatusEndpoint
private val typesEndpoint = new TypesEndpoint(config)
private val userEndpoint = new UserEndpoint(storage)
private val userEndpoint = new UserEndpoint(storage, auth)
def route: Route =
def routes: Route =
handleExceptions(exceptionHandler) {
handleRejections(rejectionHandler) {
biSEndpoint.route ~ lootEndpoint.route ~ partyEndpoint.route ~
playerEndpoint.route ~ typesEndpoint.route ~ userEndpoint.route
biSEndpoint.routes ~ lootEndpoint.routes ~ partyEndpoint.routes ~ playerEndpoint.routes ~
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

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -11,17 +11,48 @@ package me.arcanis.ffxivbis.http.api.v1
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import com.typesafe.config.Config
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema}
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.Operation
import jakarta.ws.rs._
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.models.{Job, Party, Permission, Piece, PieceType}
import me.arcanis.ffxivbis.models._
@Path("/api/v1")
class TypesEndpoint(config: Config) extends JsonSupport {
def route: Route = getJobs ~ getPermissions ~ getPieces ~ getPieceTypes ~ getPriority
def routes: Route = getAllJobs ~ getJobs ~ getPermissions ~ getPieces ~ getPieceTypes ~ getPriority
@GET
@Path("types/jobs/all")
@Produces(value = Array("application/json"))
@Operation(
summary = "full jobs list",
description = "Returns the available jobs including any job",
responses = Array(
new ApiResponse(
responseCode = "200",
description = "List of available jobs with AnyJob",
content = Array(
new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[String]))
)
)
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
),
tags = Array("types"),
)
def getAllJobs: Route =
path("types" / "jobs" / "all") {
get {
complete(Job.availableWithAnyJob.map(_.toString))
}
}
@GET
@Path("types/jobs")
@ -42,7 +73,7 @@ class TypesEndpoint(config: Config) extends JsonSupport {
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
),
tags = Array("types"),
@ -50,7 +81,7 @@ class TypesEndpoint(config: Config) extends JsonSupport {
def getJobs: Route =
path("types" / "jobs") {
get {
complete(Job.availableWithAnyJob.map(_.toString))
complete(Job.available.map(_.toString))
}
}
@ -73,7 +104,7 @@ class TypesEndpoint(config: Config) extends JsonSupport {
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
),
tags = Array("types"),
@ -104,7 +135,7 @@ class TypesEndpoint(config: Config) extends JsonSupport {
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
),
tags = Array("types"),
@ -135,7 +166,7 @@ class TypesEndpoint(config: Config) extends JsonSupport {
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
),
tags = Array("types"),
@ -166,7 +197,7 @@ class TypesEndpoint(config: Config) extends JsonSupport {
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
),
tags = Array("types"),

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -21,21 +21,24 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import jakarta.ws.rs._
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.http.{Authorization, UserHelper}
import me.arcanis.ffxivbis.http.helpers.UserHelper
import me.arcanis.ffxivbis.http.{Authorization, AuthorizationProvider}
import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.Permission
import scala.util.{Failure, Success}
@Path("/api/v1")
class UserEndpoint(override val storage: ActorRef[Message])(implicit timeout: Timeout, scheduler: Scheduler)
extends UserHelper
class UserEndpoint(override val storage: ActorRef[Message], override val auth: AuthorizationProvider)(implicit
timeout: Timeout,
scheduler: Scheduler
) extends UserHelper
with Authorization
with JsonSupport {
def route: Route = createParty ~ createUser ~ deleteUser ~ getUsers
def routes: Route = createParty ~ createUser ~ deleteUser ~ getUsers ~ getUsersCurrent
@PUT
@POST
@Path("party")
@Consumes(value = Array("application/json"))
@Operation(
@ -44,24 +47,28 @@ class UserEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
requestBody = new RequestBody(
description = "party administrator description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[UserResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[UserModel])))
),
responses = Array(
new ApiResponse(responseCode = "200", description = "Party has been created"),
new ApiResponse(
responseCode = "200",
description = "Party has been created",
content = Array(new Content(schema = new Schema(implementation = classOf[PartyIdModel])))
),
new ApiResponse(
responseCode = "400",
description = "Invalid parameters were supplied",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "406",
description = "Party with the specified ID already exists",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
),
tags = Array("party"),
@ -69,16 +76,13 @@ class UserEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
def createParty: Route =
path("party") {
extractExecutionContext { implicit executionContext =>
put {
entity(as[UserResponse]) { user =>
onComplete(newPartyId) {
case Success(partyId) =>
post {
entity(as[UserModel]) { user =>
onSuccess(newPartyId) { partyId =>
val admin = user.toUser.copy(partyId = partyId, permission = Permission.admin)
onComplete(addUser(admin, isHashedPassword = false)) {
case Success(_) => complete(PartyIdResponse(partyId))
case Failure(exception) => throw exception
onSuccess(addUser(admin, isHashedPassword = false)) {
complete(PartyIdModel(partyId))
}
case Failure(exception) => throw exception
}
}
}
@ -92,37 +96,42 @@ class UserEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
summary = "create new user",
description = "Add an user to the specified party",
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(
description = "user description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[UserResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[UserModel])))
),
responses = Array(
new ApiResponse(responseCode = "201", description = "User has been created"),
new ApiResponse(
responseCode = "400",
description = "Invalid parameters were supplied",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
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"),
)
def createUser: Route =
@ -130,11 +139,10 @@ class UserEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ =>
post {
entity(as[UserResponse]) { user =>
entity(as[UserModel]) { user =>
val withPartyId = user.toUser.copy(partyId = partyId)
onComplete(addUser(withPartyId, isHashedPassword = false)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception
onSuccess(addUser(withPartyId, isHashedPassword = false)) {
complete(StatusCodes.Accepted, HttpEntity.Empty)
}
}
}
@ -148,7 +156,12 @@ class UserEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
summary = "remove user",
description = "Remove an user from the specified party",
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"),
),
responses = Array(
@ -156,20 +169,20 @@ class UserEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
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"),
)
def deleteUser: Route =
@ -177,9 +190,8 @@ class UserEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ =>
delete {
onComplete(removeUser(partyId, username)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception
onSuccess(removeUser(partyId, username)) {
complete(StatusCodes.Accepted, HttpEntity.Empty)
}
}
}
@ -193,7 +205,12 @@ class UserEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
summary = "get users",
description = "Return the list of users belong to party",
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(
new ApiResponse(
@ -201,27 +218,27 @@ class UserEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
description = "Users list",
content = Array(
new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[UserResponse])),
array = new ArraySchema(schema = new Schema(implementation = classOf[UserModel])),
)
)
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
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("get"))),
tags = Array("users"),
)
def getUsers: Route =
@ -229,12 +246,61 @@ class UserEndpoint(override val storage: ActorRef[Message])(implicit timeout: Ti
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ =>
get {
onComplete(users(partyId)) {
case Success(response) => complete(response.map(UserResponse.fromUser))
case Failure(exception) => throw exception
onSuccess(users(partyId)) { response =>
complete(response.map(UserModel.fromUser))
}
}
}
}
}
@GET
@Path("party/{partyId}/users/current")
@Produces(value = Array("application/json"))
@Operation(
summary = "get current user",
description = "Return the current user descriptor",
parameters = Array(
new Parameter(
name = "partyId",
in = ParameterIn.PATH,
description = "unique party ID",
example = "o3KicHQPW5b0JcOm5yI3"
),
),
responses = Array(
new ApiResponse(
responseCode = "200",
description = "User descriptor",
content = Array(new Content(schema = new Schema(implementation = classOf[UserModel])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
),
security = Array(new SecurityRequirement(name = "basic", scopes = Array("admin"))),
tags = Array("users"),
)
def getUsersCurrent: Route =
path("party" / Segment / "users" / "current") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { user =>
get {
complete(UserModel.fromUser(user))
}
}
}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -9,5 +9,6 @@
package me.arcanis.ffxivbis.http.api.v1.json
object ApiAction extends Enumeration {
val add, remove = Value
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -10,4 +10,4 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
case class ErrorResponse(@Schema(description = "error message", required = true) message: String)
case class ErrorModel(@Schema(description = "error message", required = true) message: String)

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -8,20 +8,20 @@
*/
package me.arcanis.ffxivbis.http.api.v1.json
import java.time.Instant
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
import me.arcanis.ffxivbis.models.Permission
import spray.json._
import java.time.Instant
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] {
override def write(obj: E#Value): JsValue = obj.toString.toJson
override def read(json: JsValue): E#Value = json match {
case JsNumber(value) => enum(value.toInt)
case JsString(name) => enum.withName(name)
case JsNumber(value) => enumeration(value.toInt)
case JsString(name) => enumeration.withName(name)
case other => deserializationError(s"String or number expected, got $other")
}
}
@ -38,19 +38,20 @@ trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol {
implicit val actionFormat: RootJsonFormat[ApiAction.Value] = enumFormat(ApiAction)
implicit val permissionFormat: RootJsonFormat[Permission.Value] = enumFormat(Permission)
implicit val errorFormat: RootJsonFormat[ErrorResponse] = jsonFormat1(ErrorResponse.apply)
implicit val partyIdFormat: RootJsonFormat[PartyIdResponse] = jsonFormat1(PartyIdResponse.apply)
implicit val pieceFormat: RootJsonFormat[PieceResponse] = jsonFormat3(PieceResponse.apply)
implicit val lootFormat: RootJsonFormat[LootResponse] = jsonFormat3(LootResponse.apply)
implicit val partyDescriptionFormat: RootJsonFormat[PartyDescriptionResponse] = jsonFormat2(
PartyDescriptionResponse.apply
implicit val errorFormat: RootJsonFormat[ErrorModel] = jsonFormat1(ErrorModel.apply)
implicit val partyIdFormat: RootJsonFormat[PartyIdModel] = jsonFormat1(PartyIdModel.apply)
implicit val pieceFormat: RootJsonFormat[PieceModel] = jsonFormat3(PieceModel.apply)
implicit val lootFormat: RootJsonFormat[LootModel] = jsonFormat3(LootModel.apply)
implicit val partyDescriptionFormat: RootJsonFormat[PartyDescriptionModel] = jsonFormat2(
PartyDescriptionModel.apply
)
implicit val playerFormat: RootJsonFormat[PlayerResponse] = jsonFormat7(PlayerResponse.apply)
implicit val playerActionFormat: RootJsonFormat[PlayerActionResponse] = jsonFormat2(PlayerActionResponse.apply)
implicit val playerIdFormat: RootJsonFormat[PlayerIdResponse] = jsonFormat3(PlayerIdResponse.apply)
implicit val pieceActionFormat: RootJsonFormat[PieceActionResponse] = jsonFormat4(PieceActionResponse.apply)
implicit val playerBiSLinkFormat: RootJsonFormat[PlayerBiSLinkResponse] = jsonFormat2(PlayerBiSLinkResponse.apply)
implicit val playerIdWithCountersFormat: RootJsonFormat[PlayerIdWithCountersResponse] =
jsonFormat9(PlayerIdWithCountersResponse.apply)
implicit val userFormat: RootJsonFormat[UserResponse] = jsonFormat4(UserResponse.apply)
implicit val playerFormat: RootJsonFormat[PlayerModel] = jsonFormat9(PlayerModel.apply)
implicit val playerActionFormat: RootJsonFormat[PlayerActionModel] = jsonFormat2(PlayerActionModel.apply)
implicit val playerIdFormat: RootJsonFormat[PlayerIdModel] = jsonFormat3(PlayerIdModel.apply)
implicit val pieceActionFormat: RootJsonFormat[PieceActionModel] = jsonFormat4(PieceActionModel.apply)
implicit val playerBiSLinkFormat: RootJsonFormat[PlayerBiSLinkModel] = jsonFormat2(PlayerBiSLinkModel.apply)
implicit val playerIdWithCountersFormat: RootJsonFormat[PlayerIdWithCountersModel] =
jsonFormat9(PlayerIdWithCountersModel.apply)
implicit val statusFormat: RootJsonFormat[StatusModel] = jsonFormat1(StatusModel.apply)
implicit val userFormat: RootJsonFormat[UserModel] = jsonFormat4(UserModel.apply)
}

View File

@ -1,12 +1,20 @@
/*
* 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 java.time.Instant
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.Loot
case class LootResponse(
@Schema(description = "looted piece", required = true) piece: PieceResponse,
import java.time.Instant
case class LootModel(
@Schema(description = "looted piece", required = true) piece: PieceModel,
@Schema(description = "loot timestamp", required = true) timestamp: Instant,
@Schema(description = "is loot free for all", required = true) isFreeLoot: Boolean
) {
@ -14,8 +22,8 @@ case class LootResponse(
def toLoot: Loot = Loot(-1, piece.toPiece, timestamp, isFreeLoot)
}
object LootResponse {
object LootModel {
def fromLoot(loot: Loot): LootResponse =
LootResponse(PieceResponse.fromPiece(loot.piece), loot.timestamp, loot.isFreeLoot)
def fromLoot(loot: Loot): LootModel =
LootModel(PieceModel.fromPiece(loot.piece), loot.timestamp, loot.isFreeLoot)
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2020 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -11,16 +11,18 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.PartyDescription
case class PartyDescriptionResponse(
@Schema(description = "party id", required = true) partyId: String,
case class PartyDescriptionModel(
@Schema(description = "party id", required = true, example = "o3KicHQPW5b0JcOm5yI3") partyId: String,
@Schema(description = "party name") partyAlias: Option[String]
) {
) extends Validator {
require(partyAlias.forall(isValidString), stringMatchError("Party alias"))
def toDescription: PartyDescription = PartyDescription(partyId, partyAlias)
}
object PartyDescriptionResponse {
object PartyDescriptionModel {
def fromDescription(description: PartyDescription): PartyDescriptionResponse =
PartyDescriptionResponse(description.partyId, description.partyAlias)
def fromDescription(description: PartyDescription): PartyDescriptionModel =
PartyDescriptionModel(description.partyId, description.partyAlias)
}

View File

@ -0,0 +1,15 @@
/*
* 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 PartyIdModel(
@Schema(description = "party id", required = true, example = "o3KicHQPW5b0JcOm5yI3") partyId: String
)

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -10,14 +10,14 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
case class PieceActionResponse(
case class PieceActionModel(
@Schema(
description = "action to perform",
required = true,
`type` = "string",
allowableValues = Array("add", "remove")
) action: ApiAction.Value,
@Schema(description = "piece description", required = true) piece: PieceResponse,
@Schema(description = "player description", required = true) playerId: PlayerIdResponse,
@Schema(description = "is piece free to roll or not") isFreeLoot: Option[Boolean]
@Schema(description = "piece description", required = true) piece: PieceModel,
@Schema(description = "player description", required = true) playerId: PlayerIdModel,
@Schema(description = "is piece free to roll or not", `type` = "boolean") isFreeLoot: Option[Boolean]
)

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -11,8 +11,8 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{Job, Piece, PieceType}
case class PieceResponse(
@Schema(description = "piece type", required = true) pieceType: String,
case class PieceModel(
@Schema(description = "piece type", required = true, example = "Savage") pieceType: String,
@Schema(description = "job name to which piece belong or AnyJob", required = true, example = "DNC") job: String,
@Schema(description = "piece name", required = true, example = "body") piece: String
) {
@ -20,8 +20,8 @@ case class PieceResponse(
def toPiece: Piece = Piece(piece, PieceType.withName(pieceType), Job.withName(job))
}
object PieceResponse {
object PieceModel {
def fromPiece(piece: Piece): PieceResponse =
PieceResponse(piece.pieceType.toString, piece.job.toString, piece.piece)
def fromPiece(piece: Piece): PieceModel =
PieceModel(piece.pieceType.toString, piece.job.toString, piece.piece)
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -10,7 +10,7 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
case class PlayerActionResponse(
case class PlayerActionModel(
@Schema(
description = "action to perform",
required = true,
@ -18,5 +18,5 @@ case class PlayerActionResponse(
allowableValues = Array("add", "remove"),
example = "add"
) action: ApiAction.Value,
@Schema(description = "player description", required = true) playerId: PlayerResponse
@Schema(description = "player description", required = true) playerId: PlayerModel
)

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -10,11 +10,14 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
case class PlayerBiSLinkResponse(
case class PlayerBiSLinkModel(
@Schema(
description = "link to player best in slot",
required = true,
example = "https://ffxiv.ariyala.com/19V5R"
) link: String,
@Schema(description = "player description", required = true) playerId: PlayerIdResponse
)
@Schema(description = "player description", required = true) playerId: PlayerIdModel
) extends Validator {
require(isValidString(link), stringMatchError("BiS link"))
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -11,18 +11,22 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{Job, PlayerId}
case class PlayerIdResponse(
@Schema(description = "unique party ID. Required in responses", example = "abcdefgh") partyId: Option[String],
case class PlayerIdModel(
@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 = "player nick name", required = true, example = "Siuan Sanche") nick: String
) {
) extends Validator {
require(isValidString(nick), stringMatchError("Player name"))
def withPartyId(partyId: String): PlayerId =
PlayerId(partyId, Job.withName(job), nick)
}
object PlayerIdResponse {
object PlayerIdModel {
def fromPlayerId(playerId: PlayerId): PlayerIdResponse =
PlayerIdResponse(Some(playerId.partyId), playerId.job.toString, playerId.nick)
def fromPlayerId(playerId: PlayerId): PlayerIdModel =
PlayerIdModel(Some(playerId.partyId), playerId.job.toString, playerId.nick)
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -11,22 +11,22 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.PlayerIdWithCounters
case class PlayerIdWithCountersResponse(
@Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: String,
case class PlayerIdWithCountersModel(
@Schema(description = "unique party ID", required = true, example = "o3KicHQPW5b0JcOm5yI3") partyId: 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 = "is piece required by player or not", required = true) isRequired: Boolean,
@Schema(description = "player loot priority", required = true) priority: Int,
@Schema(description = "count of savage pieces in best in slot", required = true) bisCountTotal: Int,
@Schema(description = "count of looted pieces", required = true) lootCount: Int,
@Schema(description = "count of looted pieces of this type", required = true) lootCount: Int,
@Schema(description = "count of looted pieces which are parts of best in slot", required = true) lootCountBiS: Int,
@Schema(description = "total count of looted pieces", required = true) lootCountTotal: Int
)
object PlayerIdWithCountersResponse {
object PlayerIdWithCountersModel {
def fromPlayerId(playerIdWithCounters: PlayerIdWithCounters): PlayerIdWithCountersResponse =
PlayerIdWithCountersResponse(
def fromPlayerId(playerIdWithCounters: PlayerIdWithCounters): PlayerIdWithCountersModel =
PlayerIdWithCountersModel(
playerIdWithCounters.partyId,
playerIdWithCounters.job.toString,
playerIdWithCounters.nick,

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -11,15 +11,23 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{BiS, Job, Player}
case class PlayerResponse(
@Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: String,
case class PlayerModel(
@Schema(description = "unique party ID", required = true, example = "o3KicHQPW5b0JcOm5yI3") partyId: 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 = "pieces in best in slot") bis: Option[Seq[PieceResponse]],
@Schema(description = "looted pieces") loot: Option[Seq[LootResponse]],
@Schema(description = "pieces in best in slot") bis: Option[Seq[PieceModel]],
@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 = "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",
`type` = "number"
) lootCountBiS: 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 =
Player(
@ -34,16 +42,18 @@ case class PlayerResponse(
)
}
object PlayerResponse {
object PlayerModel {
def fromPlayer(player: Player): PlayerResponse =
PlayerResponse(
def fromPlayer(player: Player): PlayerModel =
PlayerModel(
player.partyId,
player.job.toString,
player.nick,
Some(player.bis.pieces.map(PieceResponse.fromPiece)),
Some(player.loot.map(LootResponse.fromLoot)),
Some(player.bis.pieces.map(PieceModel.fromPiece)),
Some(player.loot.map(LootModel.fromLoot)),
player.link,
Some(player.priority)
Some(player.priority),
Some(player.lootCountBiS),
Some(player.lootCountTotal),
)
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -10,4 +10,4 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
case class PartyIdResponse(@Schema(description = "party id", required = true) partyId: String)
case class StatusModel(@Schema(description = "server version") version: Option[String])

View File

@ -0,0 +1,41 @@
/*
* 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
import me.arcanis.ffxivbis.models.{Permission, User}
case class UserModel(
@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 = "password to login to party, required for user editing", example = "pa55w0rd") password: Option[
String
],
@Schema(
description = "user permission",
defaultValue = "get",
`type` = "string",
allowableValues = Array("get", "post", "admin")
) permission: Option[Permission.Value] = None
) extends Validator {
require(isValidString(username), stringMatchError("Username"))
require(password.forall(_.nonEmpty), "Password must not be empty")
def toUser: User =
password.fold(throw new IllegalArgumentException("Password must noot be empty"))(
User(partyId, username, _, permission.getOrElse(Permission.get))
)
}
object UserModel {
def fromUser(user: User): UserModel =
UserModel(user.partyId, user.username, None, Some(user.permission))
}

View File

@ -1,33 +0,0 @@
/*
* Copyright (c) 2019 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
import me.arcanis.ffxivbis.models.{Permission, User}
case class UserResponse(
@Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: 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 = "user permission",
defaultValue = "get",
allowableValues = Array("get", "post", "admin")
) permission: Option[Permission.Value] = None
) {
def toUser: User =
User(partyId, username, password, permission.getOrElse(Permission.get))
}
object UserResponse {
def fromUser(user: User): UserResponse =
UserResponse(user.partyId, user.username, "", 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,18 +1,19 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* 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
package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.actor.typed.{ActorRef, Scheduler}
import akka.util.Timeout
import me.arcanis.ffxivbis.http.api.v1.json.ApiAction
import me.arcanis.ffxivbis.messages.{AddPieceToBis, GetBiS, Message, RemovePieceFromBiS, RemovePiecesFromBiS}
import me.arcanis.ffxivbis.messages.DatabaseMessage._
import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId}
import scala.concurrent.{ExecutionContext, Future}
@ -44,13 +45,15 @@ trait BiSHelper extends BisProviderHelper {
timeout: Timeout,
scheduler: Scheduler
): Future[Unit] =
storage.ask(RemovePiecesFromBiS(playerId, _)).flatMap { _ =>
storage
.ask(RemovePiecesFromBiS(playerId, _))
.flatMap { _ =>
downloadBiS(link, playerId.job)
.flatMap { bis =>
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] =
storage.ask(RemovePieceFromBiS(playerId, piece, _))

View File

@ -1,17 +1,18 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* 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
package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.{ActorRef, Scheduler}
import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.actor.typed.{ActorRef, Scheduler}
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 scala.concurrent.Future
@ -20,6 +21,6 @@ trait BisProviderHelper {
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, _))
}

View File

@ -1,18 +1,19 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* 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
package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.actor.typed.{ActorRef, Scheduler}
import akka.util.Timeout
import me.arcanis.ffxivbis.http.api.v1.json.ApiAction
import me.arcanis.ffxivbis.messages.{AddPieceTo, GetLoot, Message, RemovePieceFrom, SuggestLoot}
import me.arcanis.ffxivbis.messages.DatabaseMessage._
import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId, PlayerIdWithCounters}
import scala.concurrent.{ExecutionContext, Future}
@ -33,8 +34,8 @@ trait LootHelper {
): Future[Unit] =
(action, maybeFree) match {
case (ApiAction.add, Some(isFreeLoot)) => addPieceLoot(playerId, piece, isFreeLoot)
case (ApiAction.remove, _) => removePieceLoot(playerId, piece)
case _ => throw new IllegalArgumentException(s"Invalid combinantion of action $action and fee loot $maybeFree")
case (ApiAction.remove, Some(isFreeLoot)) => removePieceLoot(playerId, piece, isFreeLoot)
case _ => throw new IllegalArgumentException("Loot modification must always contain `isFreeLoot` field")
}
def loot(partyId: String, playerId: Option[PlayerId])(implicit
@ -43,8 +44,11 @@ trait LootHelper {
): Future[Seq[Player]] =
storage.ask(GetLoot(partyId, playerId, _))
def removePieceLoot(playerId: PlayerId, piece: Piece)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(RemovePieceFrom(playerId, piece, _))
def removePieceLoot(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean)(implicit
timeout: Timeout,
scheduler: Scheduler
): Future[Unit] =
storage.ask(RemovePieceFrom(playerId, piece, isFreeLoot, _))
def suggestPiece(partyId: String, piece: Piece)(implicit
executionContext: ExecutionContext,

View File

@ -1,18 +1,19 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* 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
package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.{ActorRef, Scheduler}
import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.actor.typed.{ActorRef, Scheduler}
import akka.util.Timeout
import me.arcanis.ffxivbis.http.api.v1.json.ApiAction
import me.arcanis.ffxivbis.messages.{AddPieceToBis, AddPlayer, GetParty, GetPartyDescription, GetPlayer, Message, RemovePlayer, UpdateParty}
import me.arcanis.ffxivbis.messages.DatabaseMessage._
import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.{PartyDescription, Player, PlayerId}
import scala.concurrent.{ExecutionContext, Future}
@ -26,15 +27,15 @@ trait PlayerHelper extends BisProviderHelper {
)(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage
.ask(ref => AddPlayer(player, ref))
.map { res =>
.map { _ =>
player.link.map(_.trim).filter(_.nonEmpty) match {
case Some(link) =>
downloadBiS(link, player.job)
.map { bis =>
bis.pieces.map(piece => storage.ask(AddPieceToBis(player.playerId, piece, _)))
}
.map(_ => res)
case None => Future.successful(res)
.flatMap(_ => storage.ask(UpdateBiSLink(player.playerId, link, _)))
case None => Future.successful(())
}
}
.flatten

View File

@ -1,17 +1,19 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* 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
package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.actor.typed.{ActorRef, Scheduler}
import akka.util.Timeout
import me.arcanis.ffxivbis.messages.{AddUser, DeleteUser, GetNewPartyId, GetUser, GetUsers, Message}
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 scala.concurrent.Future

View File

@ -1,73 +0,0 @@
/*
* Copyright (c) 2019 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.view
import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import me.arcanis.ffxivbis.http.{Authorization, PlayerHelper}
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
import scala.util.{Failure, Success}
class BasePartyView(override val storage: ActorRef[Message], override val provider: ActorRef[BiSProviderMessage])(
implicit
timeout: Timeout,
scheduler: Scheduler
) extends PlayerHelper
with Authorization {
def route: Route = getIndex
def getIndex: Route =
path("party" / Segment) { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
onComplete(getPartyDescription(partyId)) {
case Success(description) =>
complete(StatusCodes.OK, RootView.toHtml(BasePartyView.template(partyId, description.alias)))
case Failure(exception) => throw exception
}
}
}
}
}
}
object BasePartyView {
import scalatags.Text
import scalatags.Text.all._
import scalatags.Text.tags2.{title => titleTag}
def root(partyId: String): Text.TypedTag[String] =
a(href := s"/party/$partyId", title := "root")("root")
def template(partyId: String, alias: String): String =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(
lang := "en",
head(
titleTag(s"Party $alias"),
link(rel := "stylesheet", `type` := "text/css", href := "/static/styles.css")
),
body(
h2(s"Party $alias"),
br,
h2(a(href := s"/party/$partyId/players", title := "party")("party")),
h2(a(href := s"/party/$partyId/bis", title := "bis management")("best in slot")),
h2(a(href := s"/party/$partyId/loot", title := "loot management")("loot")),
h2(a(href := s"/party/$partyId/suggest", title := "suggest loot")("suggest")),
hr,
h2(a(href := s"/party/$partyId/users", title := "user management")("users"))
)
)
}

View File

@ -1,173 +0,0 @@
/*
* Copyright (c) 2019 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.view
import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import me.arcanis.ffxivbis.http.{Authorization, BiSHelper}
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
import me.arcanis.ffxivbis.models.{Piece, PieceType, Player, PlayerId}
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try
class BiSView(override val storage: ActorRef[Message], override val provider: ActorRef[BiSProviderMessage])(implicit
timeout: Timeout,
scheduler: Scheduler
) extends BiSHelper
with Authorization {
def route: Route = getBiS ~ modifyBiS
def getBiS: Route =
path("party" / Segment / "bis") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
complete {
bis(partyId, None)
.map { players =>
BiSView.template(partyId, players, None)
}
.map { text =>
(StatusCodes.OK, RootView.toHtml(text))
}
}
}
}
}
}
def modifyBiS: Route =
path("party" / Segment / "bis") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
formFields(
"player".as[String],
"piece".as[String].?,
"piece_type".as[String].?,
"link".as[String].?,
"action".as[String]
) { (player, maybePiece, maybePieceType, maybeLink, action) =>
onComplete(modifyBiSCall(partyId, player, maybePiece, maybePieceType, maybeLink, action)) { _ =>
redirect(s"/party/$partyId/bis", StatusCodes.Found)
}
}
}
}
}
}
private def modifyBiSCall(
partyId: String,
player: String,
maybePiece: Option[String],
maybePieceType: Option[String],
maybeLink: Option[String],
action: String
)(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = {
def getPiece(playerId: PlayerId, piece: String, pieceType: String) =
Try(Piece(piece, PieceType.withName(pieceType), playerId.job)).toOption
def bisAction(playerId: PlayerId, piece: String, pieceType: String)(fn: Piece => Future[Unit]) =
getPiece(playerId, piece, pieceType) match {
case Some(item) => fn(item)
case _ => Future.failed(new Error(s"Could not construct piece from `$piece ($pieceType)`"))
}
PlayerId(partyId, player) match {
case Some(playerId) =>
(maybePiece, maybePieceType, action, maybeLink.map(_.trim).filter(_.nonEmpty)) match {
case (Some(piece), Some(pieceType), "add", _) =>
bisAction(playerId, piece, pieceType)(addPieceBiS(playerId, _))
case (Some(piece), Some(pieceType), "remove", _) =>
bisAction(playerId, piece, pieceType)(removePieceBiS(playerId, _))
case (_, _, "create", Some(link)) => putBiS(playerId, link)
case _ => Future.failed(new Error(s"Could not perform $action"))
}
case _ => Future.failed(new Error(s"Could not construct player id from `$player`"))
}
}
}
object BiSView {
import scalatags.Text.all._
import scalatags.Text.tags2.{title => titleTag}
def template(partyId: String, party: Seq[Player], error: Option[String]): String =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(
lang := "en",
head(
titleTag("Best in slot"),
link(rel := "stylesheet", `type` := "text/css", href := "/static/styles.css")
),
body(
h2("Best in slot"),
ErrorView.template(error),
SearchLineView.template,
form(action := s"/party/$partyId/bis", method := "post")(
select(name := "player", id := "player", title := "player")(
for (player <- party) yield option(player.playerId.toString)
),
select(name := "piece", id := "piece", title := "piece")(
for (piece <- Piece.available) yield option(piece)
),
select(name := "piece_type", id := "piece_type", title := "piece type")(
for (pieceType <- PieceType.available) yield option(pieceType.toString)
),
input(name := "action", id := "action", `type` := "hidden", value := "add"),
input(name := "add", id := "add", `type` := "submit", value := "add")
),
form(action := s"/party/$partyId/bis", method := "post")(
select(name := "player", id := "player", title := "player")(
for (player <- party) yield option(player.playerId.toString)
),
input(name := "link", id := "link", placeholder := "player bis link", title := "link", `type` := "text"),
input(name := "action", id := "action", `type` := "hidden", value := "create"),
input(name := "add", id := "add", `type` := "submit", value := "add")
),
table(id := "result")(
tr(
th("player"),
th("piece"),
th("piece type"),
th("")
),
for (player <- party; piece <- player.bis.pieces)
yield tr(
td(`class` := "include_search")(player.playerId.toString),
td(`class` := "include_search")(piece.piece),
td(piece.pieceType.toString),
td(
form(action := s"/party/$partyId/bis", method := "post")(
input(name := "player", id := "player", `type` := "hidden", value := player.playerId.toString),
input(name := "piece", id := "piece", `type` := "hidden", value := piece.piece),
input(
name := "piece_type",
id := "piece_type",
`type` := "hidden",
value := piece.pieceType.toString
),
input(name := "action", id := "action", `type` := "hidden", value := "remove"),
input(name := "remove", id := "remove", `type` := "submit", value := "x")
)
)
)
),
ExportToCSVView.template,
BasePartyView.root(partyId),
script(src := "/static/table_search.js", `type` := "text/javascript")
)
)
}

View File

@ -1,20 +0,0 @@
/*
* Copyright (c) 2019 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.view
import scalatags.Text
import scalatags.Text.all._
object ErrorView {
def template(error: Option[String]): Text.TypedTag[String] = error match {
case Some(text) => p(id := "error", s"Error occurs: $text")
case None => p("")
}
}

View File

@ -1,21 +0,0 @@
/*
* Copyright (c) 2019 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.view
import scalatags.Text
import scalatags.Text.all._
object ExportToCSVView {
def template: Text.TypedTag[String] =
div(
button(onclick := "exportTableToCsv('result.csv')")("Export to CSV"),
script(src := "/static/table_export.js", `type` := "text/javascript")
)
}

View File

@ -1,104 +0,0 @@
/*
* Copyright (c) 2019 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.view
import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import me.arcanis.ffxivbis.http.{PlayerHelper, UserHelper}
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
import me.arcanis.ffxivbis.models.{PartyDescription, Permission, User}
import scala.concurrent.Future
import scala.util.{Failure, Success}
class IndexView(override val storage: ActorRef[Message], override val provider: ActorRef[BiSProviderMessage])(implicit
timeout: Timeout,
scheduler: Scheduler
) extends PlayerHelper
with UserHelper {
def route: Route = createParty ~ getIndex
def createParty: Route =
path("party") {
extractExecutionContext { implicit executionContext =>
post {
formFields("username".as[String], "password".as[String], "alias".as[String].?) {
(username, password, maybeAlias) =>
onComplete {
newPartyId.flatMap { partyId =>
val user = User(partyId, username, password, Permission.admin)
addUser(user, isHashedPassword = false).flatMap { _ =>
if (maybeAlias.getOrElse("").isEmpty) Future.successful(partyId)
else updateDescription(PartyDescription(partyId, maybeAlias)).map(_ => partyId)
}
}
} {
case Success(partyId) => redirect(s"/party/$partyId", StatusCodes.Found)
case Failure(exception) => throw exception
}
}
}
}
}
def getIndex: Route =
pathEndOrSingleSlash {
get {
parameters("partyId".as[String].?) {
case Some(partyId) => redirect(s"/party/$partyId", StatusCodes.Found)
case _ => complete(StatusCodes.OK, RootView.toHtml(IndexView.template))
}
}
}
}
object IndexView {
import scalatags.Text.all._
import scalatags.Text.tags2.{title => titleTag}
def template: String =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(
head(
titleTag("FFXIV loot helper"),
link(rel := "stylesheet", `type` := "text/css", href := "/static/styles.css")
),
body(
form(action := s"party", method := "post")(
label("create a new party"),
input(name := "alias", id := "alias", placeholder := "party alias", title := "alias", `type` := "text"),
input(
name := "username",
id := "username",
placeholder := "username",
title := "username",
`type` := "text"
),
input(
name := "password",
id := "password",
placeholder := "password",
title := "password",
`type` := "password"
),
input(name := "add", id := "add", `type` := "submit", value := "add")
),
br,
form(action := "/", method := "get")(
label("already have party?"),
input(name := "partyId", id := "partyId", placeholder := "party id", title := "party id", `type` := "text"),
input(name := "go", id := "go", `type` := "submit", value := "go")
)
)
)
}

View File

@ -1,163 +0,0 @@
/*
* Copyright (c) 2019 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.view
import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.util.Timeout
import me.arcanis.ffxivbis.http.{Authorization, LootHelper}
import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.{Job, Piece, PieceType, PlayerIdWithCounters}
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success, Try}
class LootSuggestView(override val storage: ActorRef[Message])(implicit timeout: Timeout, scheduler: Scheduler)
extends LootHelper
with Authorization {
def route: Route = getIndex ~ suggestLoot
def getIndex: Route =
path("party" / Segment / "suggest") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
complete {
val text = LootSuggestView.template(partyId, Seq.empty, None, false, None)
(StatusCodes.OK, RootView.toHtml(text))
}
}
}
}
}
def suggestLoot: Route =
path("party" / Segment / "suggest") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
post {
formFields("piece".as[String], "job".as[String], "piece_type".as[String], "free_loot".as[String].?) {
(piece, job, pieceType, maybeFreeLoot) =>
import me.arcanis.ffxivbis.utils.Implicits._
val maybePiece = Try(Piece(piece, PieceType.withName(pieceType), Job.withName(job))).toOption
onComplete(suggestLootCall(partyId, maybePiece)) {
case Success(players) =>
val text = LootSuggestView.template(partyId, players, maybePiece, maybeFreeLoot, None)
complete(StatusCodes.OK, RootView.toHtml(text))
case Failure(exception) =>
val text = LootSuggestView.template(partyId, Seq.empty, None, false, Some(exception.getMessage))
complete(StatusCodes.OK, RootView.toHtml(text))
}
}
}
}
}
}
private def suggestLootCall(partyId: String, maybePiece: Option[Piece])(implicit
executionContext: ExecutionContext,
timeout: Timeout
): Future[Seq[PlayerIdWithCounters]] =
maybePiece match {
case Some(piece) => suggestPiece(partyId, piece)
case _ => Future.failed(new Error(s"Could not construct piece from `$maybePiece`"))
}
}
object LootSuggestView {
import scalatags.Text.all._
import scalatags.Text.tags2.{title => titleTag}
def template(
partyId: String,
party: Seq[PlayerIdWithCounters],
piece: Option[Piece],
isFreeLoot: Boolean,
error: Option[String]
): String =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(
lang := "en",
head(
titleTag("Suggest loot"),
link(rel := "stylesheet", `type` := "text/css", href := "/static/styles.css")
),
body(
h2("Suggest loot"),
for (part <- piece) yield p(s"Piece ${part.piece} (${part.pieceType})"),
ErrorView.template(error),
SearchLineView.template,
form(action := s"/party/$partyId/suggest", method := "post")(
select(name := "piece", id := "piece", title := "piece")(
for (piece <- Piece.available) yield option(piece)
),
select(name := "job", id := "job", title := "job")(
for (job <- Job.availableWithAnyJob) yield option(job.toString)
),
select(name := "piece_type", id := "piece_type", title := "piece type")(
for (pieceType <- PieceType.available) yield option(pieceType.toString)
),
input(name := "free_loot", id := "free_loot", title := "is free loot", `type` := "checkbox"),
label(`for` := "free_loot")("is free loot"),
input(name := "suggest", id := "suggest", `type` := "submit", value := "suggest")
),
table(id := "result")(
tr(
th("player"),
th("is required"),
th("these pieces looted"),
th("total bis pieces looted"),
th("total pieces looted"),
th("")
),
for (player <- party)
yield tr(
td(`class` := "include_search")(player.playerId.toString),
td(player.isRequiredToString),
td(player.lootCount),
td(player.lootCountBiS),
td(player.lootCountTotal),
td(
form(action := s"/party/$partyId/loot", method := "post")(
input(name := "player", id := "player", `type` := "hidden", value := player.playerId.toString),
input(
name := "piece",
id := "piece",
`type` := "hidden",
value := piece.map(_.piece).getOrElse("")
),
input(
name := "piece_type",
id := "piece_type",
`type` := "hidden",
value := piece.map(_.pieceType.toString).getOrElse("")
),
input(
name := "free_loot",
id := "free_loot",
`type` := "hidden",
value := (if (isFreeLoot) "yes" else "no")
),
input(name := "action", id := "action", `type` := "hidden", value := "add"),
input(name := "add", id := "add", `type` := "submit", value := "add")
)
)
)
),
ExportToCSVView.template,
BasePartyView.root(partyId),
script(src := "/static/table_search.js", `type` := "text/javascript")
)
)
}

View File

@ -1,164 +0,0 @@
/*
* Copyright (c) 2019 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.view
import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.util.Timeout
import me.arcanis.ffxivbis.http.{Authorization, LootHelper}
import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.{Piece, PieceType, Player, PlayerId}
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try
class LootView(override val storage: ActorRef[Message])(implicit timeout: Timeout, scheduler: Scheduler)
extends LootHelper
with Authorization {
def route: Route = getLoot ~ modifyLoot
def getLoot: Route =
path("party" / Segment / "loot") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
complete {
loot(partyId, None)
.map { players =>
LootView.template(partyId, players, None)
}
.map { text =>
(StatusCodes.OK, RootView.toHtml(text))
}
}
}
}
}
}
def modifyLoot: Route =
path("party" / Segment / "loot") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
formFields(
"player".as[String],
"piece".as[String],
"piece_type".as[String],
"action".as[String],
"free_loot".as[String].?
) { (player, piece, pieceType, action, isFreeLoot) =>
onComplete(modifyLootCall(partyId, player, piece, pieceType, isFreeLoot, action)) { _ =>
redirect(s"/party/$partyId/loot", StatusCodes.Found)
}
}
}
}
}
}
private def modifyLootCall(
partyId: String,
player: String,
maybePiece: String,
maybePieceType: String,
maybeFreeLoot: Option[String],
action: String
)(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = {
import me.arcanis.ffxivbis.utils.Implicits._
def getPiece(playerId: PlayerId) =
Try(Piece(maybePiece, PieceType.withName(maybePieceType), playerId.job)).toOption
PlayerId(partyId, player) match {
case Some(playerId) =>
(getPiece(playerId), action) match {
case (Some(piece), "add") => addPieceLoot(playerId, piece, maybeFreeLoot)
case (Some(piece), "remove") => removePieceLoot(playerId, piece)
case _ => Future.failed(new Error(s"Could not construct piece from `$maybePiece ($maybePieceType)`"))
}
case _ => Future.failed(new Error(s"Could not construct player id from `$player`"))
}
}
}
object LootView {
import scalatags.Text.all._
import scalatags.Text.tags2.{title => titleTag}
def template(partyId: String, party: Seq[Player], error: Option[String]): String =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(
lang := "en",
head(
titleTag("Loot"),
link(rel := "stylesheet", `type` := "text/css", href := "/static/styles.css")
),
body(
h2("Loot"),
ErrorView.template(error),
SearchLineView.template,
form(action := s"/party/$partyId/loot", method := "post")(
select(name := "player", id := "player", title := "player")(
for (player <- party) yield option(player.playerId.toString)
),
select(name := "piece", id := "piece", title := "piece")(
for (piece <- Piece.available) yield option(piece)
),
select(name := "piece_type", id := "piece_type", title := "piece type")(
for (pieceType <- PieceType.available) yield option(pieceType.toString)
),
input(name := "free_loot", id := "free_loot", title := "is free loot", `type` := "checkbox"),
label(`for` := "free_loot")("is free loot"),
input(name := "action", id := "action", `type` := "hidden", value := "add"),
input(name := "add", id := "add", `type` := "submit", value := "add")
),
table(id := "result")(
tr(
th("player"),
th("piece"),
th("piece type"),
th("is free loot"),
th("timestamp"),
th("")
),
for (player <- party; loot <- player.loot)
yield tr(
td(`class` := "include_search")(player.playerId.toString),
td(`class` := "include_search")(loot.piece.piece),
td(loot.piece.pieceType.toString),
td(loot.isFreeLootToString),
td(loot.timestamp.toString),
td(
form(action := s"/party/$partyId/loot", method := "post")(
input(name := "player", id := "player", `type` := "hidden", value := player.playerId.toString),
input(name := "piece", id := "piece", `type` := "hidden", value := loot.piece.piece),
input(
name := "piece_type",
id := "piece_type",
`type` := "hidden",
value := loot.piece.pieceType.toString
),
input(name := "free_loot", id := "free_loot", `type` := "hidden", value := loot.isFreeLootToString),
input(name := "action", id := "action", `type` := "hidden", value := "remove"),
input(name := "remove", id := "remove", `type` := "submit", value := "x")
)
)
)
),
ExportToCSVView.template,
BasePartyView.root(partyId),
script(src := "/static/table_search.js", `type` := "text/javascript")
)
)
}

View File

@ -1,152 +0,0 @@
/*
* Copyright (c) 2019 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.view
import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.util.Timeout
import me.arcanis.ffxivbis.http.{Authorization, PlayerHelper}
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
import me.arcanis.ffxivbis.models._
import scala.concurrent.{ExecutionContext, Future}
class PlayerView(override val storage: ActorRef[Message], override val provider: ActorRef[BiSProviderMessage])(implicit
timeout: Timeout,
scheduler: Scheduler
) extends PlayerHelper
with Authorization {
def route: Route = getParty ~ modifyParty
def getParty: Route =
path("party" / Segment / "players") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
complete {
getPlayers(partyId, None)
.map { players =>
PlayerView.template(partyId, players.map(_.withCounters(None)), None)
}
.map { text =>
(StatusCodes.OK, RootView.toHtml(text))
}
}
}
}
}
}
def modifyParty: Route =
path("party" / Segment / "players") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
formFields(
"nick".as[String],
"job".as[String],
"priority".as[Int].?,
"link".as[String].?,
"action".as[String]
) { (nick, job, maybePriority, maybeLink, action) =>
onComplete(modifyPartyCall(partyId, nick, job, maybePriority, maybeLink, action)) { _ =>
redirect(s"/party/$partyId/players", StatusCodes.Found)
}
}
}
}
}
}
private def modifyPartyCall(
partyId: String,
nick: String,
job: String,
maybePriority: Option[Int],
maybeLink: Option[String],
action: String
)(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = {
def maybePlayerId = PlayerId(partyId, Some(nick), Some(job))
def player(playerId: PlayerId) =
Player(-1, partyId, playerId.job, playerId.nick, BiS.empty, Seq.empty, maybeLink, maybePriority.getOrElse(0))
(action, maybePlayerId) match {
case ("add", Some(playerId)) => addPlayer(player(playerId))
case ("remove", Some(playerId)) => removePlayer(playerId)
case _ => Future.failed(new Error(s"Could not perform $action with $nick ($job)"))
}
}
}
object PlayerView {
import scalatags.Text.all._
import scalatags.Text.tags2.{title => titleTag}
def template(partyId: String, party: Seq[PlayerIdWithCounters], error: Option[String]): String =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(
lang := "en",
head(
titleTag("Party"),
link(rel := "stylesheet", `type` := "text/css", href := "/static/styles.css")
),
body(
h2("Party"),
ErrorView.template(error),
SearchLineView.template,
form(action := s"/party/$partyId/players", method := "post")(
input(name := "nick", id := "nick", placeholder := "nick", title := "nick", `type` := "nick"),
select(name := "job", id := "job", title := "job")(for (job <- Job.available) yield option(job.toString)),
input(name := "link", id := "link", placeholder := "player bis link", title := "link", `type` := "text"),
input(
name := "prioiry",
id := "priority",
placeholder := "priority",
title := "priority",
`type` := "number",
value := "0"
),
input(name := "action", id := "action", `type` := "hidden", value := "add"),
input(name := "add", id := "add", `type` := "submit", value := "add")
),
table(id := "result")(
tr(
th("nick"),
th("job"),
th("total bis pieces looted"),
th("total pieces looted"),
th("priority"),
th("")
),
for (player <- party)
yield tr(
td(`class` := "include_search")(player.nick),
td(`class` := "include_search")(player.job.toString),
td(player.lootCountBiS),
td(player.lootCountTotal),
td(player.priority),
td(
form(action := s"/party/$partyId/players", method := "post")(
input(name := "nick", id := "nick", `type` := "hidden", value := player.nick),
input(name := "job", id := "job", `type` := "hidden", value := player.job.toString),
input(name := "action", id := "action", `type` := "hidden", value := "remove"),
input(name := "remove", id := "remove", `type` := "submit", value := "x")
)
)
)
),
ExportToCSVView.template,
BasePartyView.root(partyId),
script(src := "/static/table_search.js", `type` := "text/javascript")
)
)
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -8,34 +8,73 @@
*/
package me.arcanis.ffxivbis.http.view
import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.{ContentTypes, HttpEntity}
import akka.http.scaladsl.model.headers.RawHeader
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.util.Timeout
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
import me.arcanis.ffxivbis.http.{Authorization, AuthorizationProvider}
class RootView(storage: ActorRef[Message], provider: ActorRef[BiSProviderMessage])(implicit
timeout: Timeout,
scheduler: Scheduler
class RootView(override val auth: AuthorizationProvider) extends Authorization {
def routes: Route = getBiS ~ getIndex ~ getLoot ~ getParty ~ getUsers
def getBiS: Route =
path("party" / Segment / "bis") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { user =>
respondWithHeaders(
RawHeader("X-Party-Id", partyId),
RawHeader("X-User-Permission", user.permission.toString)
) {
private val basePartyView = new BasePartyView(storage, provider)
private val indexView = new IndexView(storage, provider)
private val biSView = new BiSView(storage, provider)
private val lootView = new LootView(storage)
private val lootSuggestView = new LootSuggestView(storage)
private val playerView = new PlayerView(storage, provider)
private val userView = new UserView(storage)
def route: Route =
basePartyView.route ~ indexView.route ~
biSView.route ~ lootView.route ~ lootSuggestView.route ~ playerView.route ~ userView.route
getFromResource("html/bis.html")
}
}
}
}
object RootView {
def toHtml(template: String): HttpEntity.Strict =
HttpEntity(ContentTypes.`text/html(UTF-8)`, template)
def getIndex: Route =
pathEndOrSingleSlash {
getFromResource("html/index.html")
}
def getLoot: Route =
path("party" / Segment / "loot") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { user =>
respondWithHeaders(
RawHeader("X-Party-Id", partyId),
RawHeader("X-User-Permission", user.permission.toString)
) {
getFromResource("html/loot.html")
}
}
}
}
def getParty: Route =
path("party" / Segment) { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { user =>
respondWithHeaders(
RawHeader("X-Party-Id", partyId),
RawHeader("X-User-Permission", user.permission.toString)
) {
getFromResource("html/party.html")
}
}
}
}
def getUsers: Route =
path("party" / Segment / "users") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { user =>
respondWithHeaders(
RawHeader("X-Party-Id", partyId),
RawHeader("X-User-Permission", user.permission.toString)
) {
getFromResource("html/users.html")
}
}
}
}
}

View File

@ -1,26 +0,0 @@
/*
* Copyright (c) 2019 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.view
import scalatags.Text
import scalatags.Text.all._
object SearchLineView {
def template: Text.TypedTag[String] =
div(
input(
`type` := "text",
id := "search",
onkeyup := "searchTable()",
placeholder := "search for data",
title := "search"
)
)
}

View File

@ -1,149 +0,0 @@
/*
* Copyright (c) 2019 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.view
import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.util.Timeout
import me.arcanis.ffxivbis.http.{Authorization, UserHelper}
import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.{Permission, User}
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try
class UserView(override val storage: ActorRef[Message])(implicit timeout: Timeout, scheduler: Scheduler)
extends UserHelper
with Authorization {
def route: Route = getUsers ~ modifyUsers
def getUsers: Route =
path("party" / Segment / "users") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ =>
get {
complete {
users(partyId)
.map { users =>
UserView.template(partyId, users, None)
}
.map { text =>
(StatusCodes.OK, RootView.toHtml(text))
}
}
}
}
}
}
def modifyUsers: Route =
path("party" / Segment / "users") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ =>
post {
formFields("username".as[String], "password".as[String].?, "permission".as[String].?, "action".as[String]) {
(username, maybePassword, maybePermission, action) =>
onComplete(modifyUsersCall(partyId, username, maybePassword, maybePermission, action)) { case _ =>
redirect(s"/party/$partyId/users", StatusCodes.Found)
}
}
}
}
}
}
private def modifyUsersCall(
partyId: String,
username: String,
maybePassword: Option[String],
maybePermission: Option[String],
action: String
)(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = {
def permission: Option[Permission.Value] =
maybePermission.flatMap(p => Try(Permission.withName(p)).toOption)
action match {
case "add" =>
(maybePassword, permission) match {
case (Some(password), Some(permission)) =>
addUser(User(partyId, username, password, permission), isHashedPassword = false)
case _ =>
Future.failed(
new Error(s"Could not construct permission/password from `$maybePermission`/`$maybePassword`")
)
}
case "remove" => removeUser(partyId, username)
case _ => Future.failed(new Error(s"Could not perform $action"))
}
}
}
object UserView {
import scalatags.Text.all._
import scalatags.Text.tags2.{title => titleTag}
def template(partyId: String, users: Seq[User], error: Option[String]) =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(
lang := "en",
head(
titleTag("Users"),
link(rel := "stylesheet", `type` := "text/css", href := "/static/styles.css")
),
body(
h2("Users"),
ErrorView.template(error),
SearchLineView.template,
form(action := s"/party/$partyId/users", method := "post")(
input(
name := "username",
id := "username",
placeholder := "username",
title := "username",
`type` := "text"
),
input(
name := "password",
id := "password",
placeholder := "password",
title := "password",
`type` := "password"
),
select(name := "permission", id := "permission", title := "permission")(option("get"), option("post")),
input(name := "action", id := "action", `type` := "hidden", value := "add"),
input(name := "add", id := "add", `type` := "submit", value := "add")
),
table(id := "result")(
tr(
th("username"),
th("permission"),
th("")
),
for (user <- users)
yield tr(
td(`class` := "include_search")(user.username),
td(user.permission.toString),
td(
form(action := s"/party/$partyId/users", method := "post")(
input(name := "username", id := "username", `type` := "hidden", value := user.username.toString),
input(name := "action", id := "action", `type` := "hidden", value := "remove"),
input(name := "remove", id := "remove", `type` := "submit", value := "x")
)
)
)
),
ExportToCSVView.template,
BasePartyView.root(partyId),
script(src := "/static/table_search.js", `type` := "text/javascript")
)
)
}

View File

@ -1,3 +1,11 @@
/*
* 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.messages
import akka.actor.typed.ActorRef
@ -5,7 +13,10 @@ import me.arcanis.ffxivbis.models.{BiS, Job}
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")
}
}

View File

@ -1,10 +0,0 @@
package me.arcanis.ffxivbis.messages
import akka.actor.typed.ActorRef
import me.arcanis.ffxivbis.models.Party
case class ForgetParty(partyId: String) extends Message
case class GetNewPartyId(replyTo: ActorRef[String]) extends Message
case class StoreParty(partyId: String, party: Party) extends Message

View File

@ -0,0 +1,23 @@
/*
* 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.messages
import akka.actor.typed.ActorRef
import me.arcanis.ffxivbis.models.Party
sealed trait ControlMessage extends Message
object ControlMessage {
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

@ -1,79 +1,131 @@
/*
* 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.messages
import akka.actor.typed.{ActorRef, Behavior}
import me.arcanis.ffxivbis.models.{Party, PartyDescription, Piece, Player, PlayerId, User}
import akka.actor.typed.ActorRef
import me.arcanis.ffxivbis.models._
import me.arcanis.ffxivbis.service.LootSelector
sealed trait DatabaseMessage extends Message {
def partyId: String
def isReadOnly: Boolean
}
object DatabaseMessage {
type Handler = PartialFunction[DatabaseMessage, Behavior[DatabaseMessage]]
}
// bis handler
case class AddPieceToBis(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
trait BisDatabaseMessage extends DatabaseMessage
case class AddPieceToBis(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends BisDatabaseMessage {
override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
}
case class GetBiS(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]]) extends DatabaseMessage
case class RemovePieceFromBiS(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
case class GetBiS(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]])
extends BisDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class RemovePiecesFromBiS(playerId: PlayerId, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
case class RemovePieceFromBiS(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends BisDatabaseMessage {
override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
}
case class RemovePiecesFromBiS(playerId: PlayerId, replyTo: ActorRef[Unit]) extends BisDatabaseMessage {
override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
}
// loot handler
trait LootDatabaseMessage extends DatabaseMessage
case class AddPieceTo(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean, replyTo: ActorRef[Unit])
extends DatabaseMessage {
override def partyId: String = playerId.partyId
extends LootDatabaseMessage {
override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
}
case class GetLoot(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]]) extends DatabaseMessage
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, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
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 DatabaseMessage
extends LootDatabaseMessage {
override val isReadOnly: Boolean = true
}
// party handler
case class AddPlayer(player: Player, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = player.partyId
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 DatabaseMessage
case class GetPartyDescription(partyId: String, replyTo: ActorRef[PartyDescription]) extends DatabaseMessage
case class GetPlayer(playerId: PlayerId, replyTo: ActorRef[Option[Player]]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
case class GetParty(partyId: String, replyTo: ActorRef[Party]) extends PartyDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class RemovePlayer(playerId: PlayerId, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
case class GetPartyDescription(partyId: String, replyTo: ActorRef[PartyDescription]) extends PartyDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class UpdateParty(partyDescription: PartyDescription, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = partyDescription.partyId
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
case class AddUser(user: User, isHashedPassword: Boolean, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = user.partyId
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 DatabaseMessage
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 DatabaseMessage
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 DatabaseMessage
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 DatabaseMessage
case class GetUsers(partyId: String, replyTo: ActorRef[Seq[User]]) extends UserDatabaseMessage {
override val isReadOnly: Boolean = true
}
}

View File

@ -1,3 +1,11 @@
/*
* 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.messages
import akka.actor.typed.Behavior
@ -5,5 +13,6 @@ import akka.actor.typed.Behavior
trait Message
object Message {
type Handler = PartialFunction[Message, Behavior[Message]]
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -11,20 +11,21 @@ package me.arcanis.ffxivbis.models
case class BiS(pieces: Seq[Piece]) {
def hasPiece(piece: Piece): Boolean = piece match {
case upgrade: PieceUpgrade => upgrades.contains(upgrade)
case upgrade: Piece.PieceUpgrade => upgrades.contains(upgrade)
case _ => pieces.contains(piece)
}
def upgrades: Map[PieceUpgrade, Int] =
def upgrades: Map[Piece.PieceUpgrade, Int] =
pieces
.groupBy(_.upgrade)
.foldLeft(Map.empty[PieceUpgrade, Int]) {
.foldLeft(Map.empty[Piece.PieceUpgrade, Int]) {
case (acc, (Some(k), v)) => acc + (k -> v.size)
case (acc, _) => acc
}
.withDefaultValue(0)
def withPiece(piece: Piece): BiS = copy(pieces :+ piece)
def withoutPiece(piece: Piece): BiS = copy(pieces.filterNot(_.strictEqual(piece)))
override def equals(obj: Any): Boolean = {
@ -42,5 +43,5 @@ case class BiS(pieces: Seq[Piece]) {
object BiS {
def empty: BiS = BiS(Seq.empty)
val empty: BiS = BiS(Seq.empty)
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -8,6 +8,26 @@
*/
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 {
sealed trait RightSide
@ -26,53 +46,38 @@ object Job {
object BodyTanks 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 {
val leftSide: LeftSide = null
val rightSide: RightSide = null
override val leftSide: LeftSide = null
override val rightSide: RightSide = null
}
trait Casters extends Job {
val leftSide: LeftSide = BodyCasters
val rightSide: RightSide = AccessoriesInt
override val leftSide: LeftSide = BodyCasters
override val rightSide: RightSide = AccessoriesInt
}
trait Healers extends Job {
val leftSide: LeftSide = BodyHealers
val rightSide: RightSide = AccessoriesMnd
override val leftSide: LeftSide = BodyHealers
override val rightSide: RightSide = AccessoriesMnd
}
trait Mnks extends Job {
val leftSide: LeftSide = BodyMnks
val rightSide: RightSide = AccessoriesStr
override val leftSide: LeftSide = BodyMnks
override val rightSide: RightSide = AccessoriesStr
}
trait Drgs extends Job {
val leftSide: LeftSide = BodyDrgs
val rightSide: RightSide = AccessoriesStr
override val leftSide: LeftSide = BodyDrgs
override val rightSide: RightSide = AccessoriesStr
}
trait Nins extends Job {
override val leftSide: LeftSide = BodyNins
override val rightSide: RightSide = AccessoriesDex
}
trait Tanks extends Job {
val leftSide: LeftSide = BodyTanks
val rightSide: RightSide = AccessoriesVit
override val leftSide: LeftSide = BodyTanks
override val rightSide: RightSide = AccessoriesVit
}
trait Ranges extends Job {
val leftSide: LeftSide = BodyRanges
val rightSide: RightSide = AccessoriesDex
override val leftSide: LeftSide = BodyRanges
override val rightSide: RightSide = AccessoriesDex
}
case object PLD extends Tanks
@ -88,11 +93,9 @@ object Job {
case object MNK extends Mnks
case object DRG extends Drgs
case object RPR extends Drgs
case object NIN extends Job {
val leftSide: LeftSide = BodyNins
val rightSide: RightSide = AccessoriesDex
}
case object NIN extends Nins
case object SAM extends Mnks
case object VPR extends Mnks
case object BRD extends Ranges
case object MCH extends Ranges
@ -101,12 +104,13 @@ object Job {
case object BLM extends Casters
case object SMN extends Casters
case object RDM extends Casters
case object PCT extends Casters
lazy val available: Seq[Job] =
Seq(PLD, WAR, DRK, GNB, WHM, SCH, AST, SGE, MNK, DRG, RPR, NIN, SAM, BRD, MCH, DNC, BLM, SMN, RDM)
lazy val availableWithAnyJob: Seq[Job] = available.prepended(AnyJob)
val available: Seq[Job] =
Seq(PLD, WAR, DRK, GNB, WHM, SCH, AST, SGE, MNK, DRG, RPR, NIN, SAM, VPR, BRD, MCH, DNC, BLM, SMN, RDM, PCT)
val availableWithAnyJob: Seq[Job] = available.prepended(AnyJob)
def withName(job: String): Job.Job =
def withName(job: String): Job =
availableWithAnyJob.find(_.toString.equalsIgnoreCase(job)) match {
case Some(value) => value
case None if job.isEmpty => AnyJob

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -12,5 +12,5 @@ import java.time.Instant
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

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -14,19 +14,23 @@ import me.arcanis.ffxivbis.service.LootSelector
import scala.jdk.CollectionConverters._
import scala.util.Random
import scala.util.control.NonFatal
case class Party(partyDescription: PartyDescription, rules: Seq[String], players: Map[PlayerId, Player])
extends StrictLogging {
require(players.keys.forall(_.partyId == partyDescription.partyId), "party id must be same")
def getPlayers: Seq[Player] = players.values.toSeq
def player(playerId: PlayerId): Option[Player] = players.get(playerId)
def withPlayer(player: Player): Party =
try {
require(player.partyId == partyDescription.partyId, "player must belong to this party")
copy(players = players + (player.playerId -> player))
} catch {
case exception: Exception =>
case NonFatal(exception) =>
logger.error("cannot add player", exception)
this
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2020 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -8,10 +8,7 @@
*/
package me.arcanis.ffxivbis.models
case class PartyDescription(partyId: String, partyAlias: Option[String]) {
def alias: String = partyAlias.getOrElse(partyId)
}
case class PartyDescription(partyId: String, partyAlias: Option[String])
object PartyDescription {

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -10,18 +10,20 @@ package me.arcanis.ffxivbis.models
sealed trait Piece extends Equals {
def pieceType: PieceType.PieceType
def job: Job.Job
def pieceType: PieceType
def job: Job
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
Some(this).collect {
case _: PieceAccessory if isTome => AccessoryUpgrade
case _: PieceBody if isTome => BodyUpgrade
case _: PieceWeapon if isTome => WeaponUpgrade
case _: Piece.PieceAccessory if isTome => Piece.AccessoryUpgrade
case _: Piece.PieceBody if isTome => Piece.BodyUpgrade
case _: Piece.PieceWeapon if isTome => Piece.WeaponUpgrade
}
}
@ -29,59 +31,61 @@ sealed trait Piece extends Equals {
def strictEqual(obj: Any): Boolean = equals(obj)
}
object Piece {
trait PieceAccessory extends 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
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 Weapon(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceWeapon {
val piece: String = "weapon"
def withJob(other: Job.Job): Piece = copy(job = other)
case class Weapon(override val pieceType: PieceType, override val job: Job) extends PieceWeapon {
override val piece: String = "weapon"
override def withJob(other: Job): Piece = copy(job = other)
}
case class Head(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody {
val piece: String = "head"
def withJob(other: Job.Job): Piece = copy(job = other)
case class Head(override val pieceType: PieceType, override val job: Job) extends PieceBody {
override val piece: String = "head"
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 Body(override val pieceType: PieceType, override val job: Job) extends PieceBody {
override val piece: String = "body"
override def withJob(other: 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 Hands(override val pieceType: PieceType, override val job: Job) extends PieceBody {
override val piece: String = "hands"
override def withJob(other: 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 Legs(override val pieceType: PieceType, override val job: Job) extends PieceBody {
override val piece: String = "legs"
override def withJob(other: 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 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.PieceType, override val job: Job.Job) extends PieceAccessory {
val piece: String = "ears"
def withJob(other: Job.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.PieceType, override val job: Job.Job) extends PieceAccessory {
val piece: String = "neck"
def withJob(other: Job.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.PieceType, override val job: Job.Job) extends PieceAccessory {
val piece: String = "wrist"
def withJob(other: Job.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.PieceType,
override val job: Job.Job,
override val pieceType: PieceType,
override val job: Job,
override val piece: String = "ring"
) 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 {
case Ring(thatPieceType, thatJob, _) => (thatPieceType == pieceType) && (thatJob == job)
@ -95,22 +99,21 @@ case class Ring(
}
case object AccessoryUpgrade extends PieceUpgrade {
val piece: String = "accessory upgrade"
override val piece: String = "accessory upgrade"
}
case object BodyUpgrade extends PieceUpgrade {
val piece: String = "body upgrade"
override val piece: String = "body upgrade"
}
case object WeaponUpgrade extends PieceUpgrade {
val piece: String = "weapon upgrade"
override val piece: String = "weapon upgrade"
}
object Piece {
def apply(piece: String, pieceType: PieceType.PieceType, job: Job.Job = Job.AnyJob): Piece =
def apply(piece: String, pieceType: PieceType, job: Job = Job.AnyJob): Piece =
piece.toLowerCase match {
case "weapon" => Weapon(pieceType, job)
case "head" => Head(pieceType, job)
case "body" => Body(pieceType, job)
case "hands" => Hands(pieceType, job)
case "hand" | "hands" => Hands(pieceType, job)
case "legs" => Legs(pieceType, job)
case "feet" => Feet(pieceType, job)
case "ears" => Ears(pieceType, job)
@ -123,7 +126,7 @@ object Piece {
case other => throw new Error(s"Unknown item type $other")
}
lazy val available: Seq[String] = Seq(
val available: Seq[String] = Seq(
"weapon",
"head",
"body",

View File

@ -1,16 +1,23 @@
/*
* 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.models
object PieceType {
sealed trait PieceType
case object Crafted extends PieceType
case object Tome extends PieceType
object PieceType {
case object Savage extends PieceType
case object Tome extends PieceType
case object Crafted extends PieceType
case object Artifact extends PieceType
lazy val available: Seq[PieceType] =
Seq(Crafted, Tome, Savage, Artifact)
val available: Seq[PieceType] = Seq(Savage, Tome, Crafted, Artifact)
def withName(pieceType: String): PieceType =
available.find(_.toString.equalsIgnoreCase(pieceType)) match {

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -11,7 +11,7 @@ package me.arcanis.ffxivbis.models
case class Player(
id: Long,
partyId: String,
job: Job.Job,
job: Job,
nick: String,
bis: BiS,
loot: Seq[Loot],
@ -21,10 +21,12 @@ case class Player(
require(job ne Job.AnyJob, "AnyJob is not allowed")
val playerId: PlayerId = PlayerId(partyId, job, nick)
def withBiS(set: Option[BiS]): Player = set match {
case Some(value) => copy(bis = value)
case None => this
}
def withCounters(piece: Option[Piece]): PlayerIdWithCounters =
PlayerIdWithCounters(
partyId,
@ -32,12 +34,14 @@ case class Player(
nick,
isRequired(piece),
priority,
bisCountTotal(piece),
bisCountTotal,
lootCount(piece),
lootCountBiS(piece),
lootCountTotal(piece)
lootCountBiS,
lootCountTotal
)
def withLoot(piece: Loot): Player = withLoot(Seq(piece))
def withLoot(list: Seq[Loot]): Player = {
require(loot.forall(_.playerId == id), "player id must be same")
copy(loot = loot ++ list)
@ -47,16 +51,20 @@ case class Player(
piece match {
case None => 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
}
def bisCountTotal(piece: Option[Piece]): Int = bis.pieces.count(_.pieceType == PieceType.Savage)
def bisCountTotal: Int = bis.pieces.count(_.pieceType == PieceType.Savage)
def lootCount(piece: Option[Piece]): Int = piece match {
case Some(p) => loot.count(item => !item.isFreeLoot && item.piece == p)
case None => lootCountTotal(piece)
case None => lootCountTotal
}
def lootCountBiS(piece: Option[Piece]): Int = loot.map(_.piece).count(bis.hasPiece)
def lootCountTotal(piece: Option[Piece]): Int = loot.count(!_.isFreeLoot)
def lootPriority(piece: Piece): Int = priority
def lootCountBiS: Int = loot.map(_.piece).count(bis.hasPiece)
def lootCountTotal: Int = loot.count(!_.isFreeLoot)
def lootPriority: Int = priority
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -13,13 +13,14 @@ import scala.util.matching.Regex
trait PlayerIdBase {
def job: Job.Job
def job: Job
def nick: String
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 {

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -10,7 +10,7 @@ package me.arcanis.ffxivbis.models
case class PlayerIdWithCounters(
partyId: String,
job: Job.Job,
job: Job,
nick: String,
isRequired: Boolean,
priority: Int,
@ -23,7 +23,7 @@ case class PlayerIdWithCounters(
def gt(that: PlayerIdWithCounters, orderBy: Seq[String]): Boolean =
withCounters(orderBy) > that.withCounters(orderBy)
def isRequiredToString: String = if (isRequired) "yes" else "no"
def playerId: PlayerId = PlayerId(partyId, job, nick)
private val counters: Map[String, Int] = Map(
@ -42,15 +42,16 @@ case class PlayerIdWithCounters(
object PlayerIdWithCounters {
private case class PlayerCountersComparator(values: Int*) {
def >(that: PlayerCountersComparator): Boolean = {
@scala.annotation.tailrec
def compareLists(left: List[Int], right: List[Int]): Boolean =
def compare(left: Seq[Int], right: Seq[Int]): Boolean =
(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 (_, _) => false
}
compareLists(values.toList, that.values.toList)
compare(values, that.values)
}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -11,13 +11,17 @@ package me.arcanis.ffxivbis.models
import org.mindrot.jbcrypt.BCrypt
object Permission extends Enumeration {
val get, post, admin = Value
}
case class User(partyId: String, username: String, password: String, permission: Permission.Value) {
def hash: String = BCrypt.hashpw(password, BCrypt.gensalt)
def verify(plain: String): Boolean = BCrypt.checkpw(plain, password)
def verityScope(scope: Permission.Value): Boolean = permission >= scope
def withHashedPassword: User = copy(password = hash)
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -9,14 +9,13 @@
package me.arcanis.ffxivbis.service
import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.actor.typed.{ActorRef, Behavior, DispatcherSelector, Scheduler}
import akka.actor.typed.scaladsl.{AbstractBehavior, ActorContext, Behaviors}
import akka.actor.typed.{ActorRef, Behavior, DispatcherSelector, Scheduler}
import akka.util.Timeout
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.messages.{DatabaseMessage, Exists, ForgetParty, GetNewPartyId, GetParty, Message, StoreParty}
import me.arcanis.ffxivbis.messages._
import me.arcanis.ffxivbis.models.Party
import scala.concurrent.duration.FiniteDuration
import scala.concurrent.{ExecutionContext, Future}
class PartyService(context: ActorContext[Message], storage: ActorRef[DatabaseMessage])
@ -24,36 +23,37 @@ class PartyService(context: ActorContext[Message], storage: ActorRef[DatabaseMes
with StrictLogging {
import me.arcanis.ffxivbis.utils.Implicits._
private val cacheTimeout: FiniteDuration =
context.system.settings.config.getDuration("me.arcanis.ffxivbis.settings.cache-timeout")
private val cacheTimeout =
context.system.settings.config.getFiniteDuration("me.arcanis.ffxivbis.settings.cache-timeout")
implicit private val executionContext: ExecutionContext = {
val selector = DispatcherSelector.fromConfig("me.arcanis.ffxivbis.default-dispatcher")
context.system.dispatchers.lookup(selector)
}
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
override def onMessage(msg: Message): Behavior[Message] = handle(Map.empty)(msg)
private def handle(cache: Map[String, Party]): Message.Handler = {
case ForgetParty(partyId) =>
case ControlMessage.ForgetParty(partyId) =>
Behaviors.receiveMessage(handle(cache - partyId))
case GetNewPartyId(client) =>
case ControlMessage.GetNewPartyId(client) =>
getPartyId.foreach(client ! _)
Behaviors.same
case StoreParty(partyId, party) =>
case ControlMessage.StoreParty(partyId, party) =>
Behaviors.receiveMessage(handle(cache.updated(partyId, party)))
case GetParty(partyId, client) =>
case DatabaseMessage.GetParty(partyId, client) =>
val party = cache.get(partyId) match {
case Some(party) => Future.successful(party)
case None =>
storage.ask(ref => GetParty(partyId, ref)).map { party =>
context.self ! StoreParty(partyId, party)
context.system.scheduler.scheduleOnce(cacheTimeout, () => context.self ! ForgetParty(partyId))
storage.ask(ref => DatabaseMessage.GetParty(partyId, ref)).map { party =>
context.self ! ControlMessage.StoreParty(partyId, party)
context.system.scheduler
.scheduleOnce(cacheTimeout, () => context.self ! ControlMessage.ForgetParty(partyId))
party
}
}
@ -62,12 +62,13 @@ class PartyService(context: ActorContext[Message], storage: ActorRef[DatabaseMes
case req: DatabaseMessage =>
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] = {
val partyId = Party.randomPartyId
storage.ask(ref => Exists(partyId, ref)).flatMap {
storage.ask(ref => DatabaseMessage.Exists(partyId, ref)).flatMap {
case true => getPartyId
case false => Future.successful(partyId)
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
@ -8,19 +8,20 @@
*/
package me.arcanis.ffxivbis.service.bis
import java.nio.file.Paths
import akka.actor.ClassicActorSystemProvider
import akka.actor.typed.{Behavior, PostStop, Signal}
import akka.actor.typed.scaladsl.{AbstractBehavior, ActorContext, Behaviors}
import akka.actor.typed.{Behavior, PostStop, Signal}
import akka.http.scaladsl.model._
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.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 java.nio.file.Paths
import scala.concurrent.{ExecutionContext, Future}
import scala.util.control.NonFatal
import scala.util.{Failure, Success}
class BisProvider(context: ActorContext[BiSProviderMessage])
@ -32,7 +33,7 @@ class BisProvider(context: ActorContext[BiSProviderMessage])
override def onMessage(msg: BiSProviderMessage): Behavior[BiSProviderMessage] =
msg match {
case DownloadBiS(link, job, client) =>
case BiSProviderMessage.DownloadBiS(link, job, client) =>
get(link, job).onComplete {
case Success(items) => client ! BiS(items)
case Failure(exception) =>
@ -46,16 +47,19 @@ class BisProvider(context: ActorContext[BiSProviderMessage])
Behaviors.same
}
private def get(link: String, job: Job.Job): Future[Seq[Piece]] =
private def get(link: String, job: Job): Future[Seq[Piece]] =
try {
val url = Uri(link)
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)
sendRequest(uri, BisProvider.parseBisJsonToPieces(job, parser, getPieceType))
} 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))
private def parseBisJsonToPieces(
job: Job.Job,
job: Job,
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]] =
idParser.parse(job, js).flatMap { pieces =>
pieceTypes(pieces.values.toSeq).map { types =>
@ -80,12 +84,11 @@ object BisProvider {
}
}
def remapKey(key: String): Option[String] = key match {
case "mainhand" => Some("weapon")
case "chest" => Some("body")
case "ringLeft" | "fingerL" => Some("left ring")
case "ringRight" | "fingerR" => Some("right ring")
case "weapon" | "head" | "body" | "hands" | "legs" | "feet" | "ears" | "neck" | "wrist" | "wrists" => Some(key)
case _ => None
def remapKey(key: String): Option[String] = Some(key.toLowerCase).collect {
case "mainhand" => "weapon"
case "chest" => "body"
case "ringleft" | "fingerl" => "left ring"
case "ringright" | "fingerr" => "right ring"
case "weapon" | "head" | "body" | "hand" | "hands" | "legs" | "feet" | "ears" | "neck" | "wrist" | "wrists" => key
}
}

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