14 Commits

Author SHA1 Message Date
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
a6991a0a91 Release 0.9.13 2022-01-07 15:49:05 +03:00
5ec372be87 add item cache 2022-01-07 15:24:29 +03:00
bcdc88fa2c swagger fixes 2022-01-06 19:24:05 +03:00
53b42a6fa8 exception safety, github actions and scalafmt 2022-01-06 19:01:30 +03:00
99ed2705a2 another test for bis part 2022-01-06 06:19:57 +03:00
0ed9e92441 release 0.9.12 2021-12-19 22:44:39 +03:00
1866a1bb12 endwalker support
* added sge and rpr
* changed way to define savage gear
* libraries update
2021-12-19 22:39:20 +03:00
08f7f4571e Release 0.9.11
change loggin, more tests, cosmetic changes
2020-12-12 20:15:14 +03:00
d9cbb6cf00 Release 0.9.10 2020-12-04 12:08:34 +03:00
df8e09f02c fix column names 2020-12-04 12:08:08 +03:00
135 changed files with 5608 additions and 2562 deletions

40
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,40 @@
name: release
on:
push:
tags:
- '*.*.*'
jobs:
make-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: extract version
id: version
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
- name: create changelog
id: changelog
uses: jaywcjlove/changelog-generator@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
filter: 'Release \d+\.\d+\.\d+'
- name: setup JDK
uses: actions/setup-java@v2
with:
distribution: temurin
java-version: 17
- name: create dist
run: make dist
- name: release
uses: softprops/action-gh-release@v1
with:
body: |
${{ steps.changelog.outputs.compareurl }}
${{ steps.changelog.outputs.changelog }}
files: target/universal/ffxivbis-*.zip
fail_on_unmatched_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

22
.github/workflows/run-tests.yml vendored Normal file
View File

@ -0,0 +1,22 @@
name: tests
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
run-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: setup JDK
uses: actions/setup-java@v2
with:
distribution: temurin
java-version: 17
- name: run tests
run: make tests

35
.scalafmt.conf Normal file
View File

@ -0,0 +1,35 @@
version = 3.3.1
runner.dialect = "scala213"
maxColumn = 120
align.preset = none
continuationIndent {
defnSite = 2
extendSite = 2
}
rewrite {
rules = [
AvoidInfix,
RedundantBraces,
RedundantParens,
SortImports,
SortModifiers
]
redundantBraces {
generalExpressions = yes
ifElseExpressions = yes
includeUnitMethods = yes
methodBodies = yes
parensForOneLineApply = yes
stringInterpolation = yes
}
}
importSelectors = singleLine
trailingCommas = preserve

View File

@ -1,9 +0,0 @@
language: scala
scala:
- 2.13.1
sbt_args: -no-colors
script:
- sbt compile
- sbt test

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

@ -1,8 +1,8 @@
# FFXIV BiS
[![Build Status](https://travis-ci.org/arcan1s/ffxivbis.svg?branch=master)](https://travis-ci.org/arcan1s/ffxivbis) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/arcan1s/ffxivbis)
[![Build status](https://github.com/arcan1s/ffxivbis/actions/workflows/run-tests.yml/badge.svg)](https://github.com/arcan1s/ffxivbis/actions/workflows/run-tests.yml) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/arcan1s/ffxivbis)
Service which allows to manage savage loot distribution easy.
Service which allows managing savage loot distribution easy.
## Installation and usage
@ -12,7 +12,7 @@ In general compilation process looks like:
sbt dist
```
Or alternatively you can download latest distribution zip from the releases page. Service can be run by using command:
Or alternatively you can download the latest distribution zip from the releases page. Service can be run by using command:
```bash
bin/ffxivbis

View File

@ -1,6 +1,6 @@
name := "ffxivbis"
scalaVersion := "2.13.1"
scalaVersion := "2.13.6"
scalacOptions ++= Seq("-deprecation", "-feature")

56
extract_items.py Normal file
View File

@ -0,0 +1,56 @@
import json
import requests
# NOTE: it does not cover all items, just workaround to extract most gear pieces from patches
MIN_ILVL = 580
MAX_ILVL = 605
TOME = (
'radiant',
)
SAVAGE = (
'asphodelos',
)
payload = {
'queries': [
{
'slots': []
},
{
'jobs': [],
'minItemLevel': 580,
'maxItemLevel': 605
}
],
'existing': []
}
# it does not support application/json
r = requests.post('https://ffxiv.ariyala.com/items.app', data=json.dumps(payload))
r.raise_for_status()
result = []
for item in r.json():
item_id = item['itemID']
source_dict = item['source']
name = item['name']['en']
if 'crafting' in source_dict:
source = 'Crafted'
elif 'gathering' in source_dict:
continue # some random shit
elif 'purchase' in source_dict:
if any(tome in name.lower() for tome in TOME):
source = 'Tome'
elif any(savage in name.lower() for savage in SAVAGE):
source = 'Savage'
else:
source = None
continue
else:
raise RuntimeError(f'Unknown source {source_dict}')
result.append({'id': item_id, 'source': source, 'name': name})
output = {'cached-items': result}
print(json.dumps(output, indent=4, sort_keys=True))

View File

@ -1,32 +1,33 @@
val AkkaVersion = "2.6.10"
val AkkaHttpVersion = "10.2.1"
val AkkaVersion = "2.6.17"
val AkkaHttpVersion = "10.2.7"
val ScalaTestVersion = "3.2.10"
val SlickVersion = "3.3.3"
libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.3"
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2"
libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.9"
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.4"
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.3.0"
libraryDependencies += "javax.ws.rs" % "javax.ws.rs-api" % "2.1.1"
libraryDependencies += "com.github.swagger-akka-http" %% "swagger-akka-http" % "2.6.0"
libraryDependencies += "jakarta.platform" % "jakarta.jakartaee-web-api" % "9.1.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" % "6.0.6"
libraryDependencies += "org.xerial" % "sqlite-jdbc" % "3.32.3.2"
libraryDependencies += "org.postgresql" % "postgresql" % "42.2.18"
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.mindrot" % "jbcrypt" % "0.3m"
libraryDependencies += "org.mindrot" % "jbcrypt" % "0.4"
libraryDependencies += "com.google.guava" % "guava" % "31.0.1-jre"
// testing
libraryDependencies += "org.scalactic" %% "scalactic" % "3.1.4" % "test"
libraryDependencies += "org.scalatest" %% "scalatest" % "3.1.4" % "test"
libraryDependencies += "org.scalactic" %% "scalactic" % ScalaTestVersion % "test"
libraryDependencies += "org.scalatest" %% "scalatest" % ScalaTestVersion % "test"
libraryDependencies += "com.typesafe.akka" %% "akka-actor-testkit-typed" % AkkaVersion % "test"
libraryDependencies += "com.typesafe.akka" %% "akka-stream-testkit" % AkkaVersion % "test"

View File

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

View File

@ -0,0 +1 @@
alter table parties rename column player_id to party_id;

View File

@ -0,0 +1,11 @@
create table parties_new (
party_id integer primary key autoincrement,
party_name text not null,
party_alias text);
insert into parties_new select player_id, party_name, party_alias from parties;
drop index parties_party_name_idx;
drop table parties;
alter table parties_new rename to parties;
create unique index parties_party_name_idx on parties(party_name);

View File

@ -0,0 +1,349 @@
<!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 href="/static/favicon.ico" rel="shortcut icon">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet" type="text/css">
<link href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" rel="stylesheet" type="text/css">
<link href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" rel="stylesheet" type="text/css">
<link href="/static/styles.css" rel="stylesheet" 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-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">
<div class="modal-header">
<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>
</div>
<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"></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" onkeyup="disableSubmitBisButton()">
</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="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addPiece()" disabled>add</button>
<button id="submit-update-bis-btn" type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="setBis()" style="display: none" disabled>set</button>
</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>
</ul>
<ul class="nav">
<li><a 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.19.1/dist/bootstrap-table.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/export/bootstrap-table-export.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/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 submitUpdateBisButton = $("#submit-update-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: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
function disableSubmitBisButton() {
const nonEmpty = (playerInput.val() !== null); // well lol
submitUpdateBisButton.attr("disabled", !(nonEmpty && linkInput.val()));
submitAddBisButton.attr("disabled", !(nonEmpty));
}
function hideControls() {
removeButton.attr("hidden", isReadOnly);
updateButton.attr("hidden", isReadOnly);
}
function hideLinkPart() {
disableSubmitBisButton();
bisLinkRow.hide();
submitUpdateBisButton.hide();
pieceRow.show();
pieceTypeRow.show();
submitAddBisButton.show();
}
function hidePiecePart() {
disableSubmitBisButton();
bisLinkRow.show();
submitUpdateBisButton.show();
pieceRow.hide();
pieceTypeRow.hide();
submitAddBisButton.hide();
}
function reload() {
table.bootstrapTable("showLoading");
$.ajax({
url: `/api/v1/party/${partyId}`,
type: "GET",
dataType: "json",
success: function (data) {
const items = data.map(function (player) {
return player.bis.map(function (loot) {
return {
nick: player.nick,
job: player.job,
piece: loot.piece,
pieceType: loot.pieceType,
};
});
});
const payload = items.reduce(function (left, right) { return left.concat(right); }, []);
table.bootstrapTable("load", payload);
table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading");
const options = data.map(function (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);
disableSubmitBisButton();
},
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
function removePiece() {
const pieces = table.bootstrapTable("getSelections");
pieces.map(function (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: function (_) { reload(); },
error: function (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: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
$(function () {
setupFormClear(updateBisDialog, reset);
setupRemoveButton(table, removeButton);
loadHeader(partyId);
loadTypes("/api/v1/types/pieces", pieceInput);
loadTypes("/api/v1/types/pieces/types", pieceTypeInput);
hideControls();
updateBisButton.click(function () { reset(); });
addPieceButton.click(function () { reset(); });
table.bootstrapTable({});
reload();
});
</script>
</body>
</html>

View File

@ -0,0 +1,183 @@
<!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 href="/static/favicon.ico" rel="shortcut icon">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link href="/static/styles.css" rel="stylesheet" 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"></ul>
<ul class="nav">
<li><a 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>
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: "PUT",
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 () {
signinButton.click(function () { reset(); });
signupButton.click(function () { reset(); });
});
</script>
</body>
</html>

View File

@ -0,0 +1,338 @@
<!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 href="/static/favicon.ico" rel="shortcut icon">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet" type="text/css">
<link href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" rel="stylesheet" type="text/css">
<link href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" rel="stylesheet" type="text/css">
<link href="/static/styles.css" rel="stylesheet" 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-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">
<div class="modal-content">
<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>
<form 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"></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"></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"></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"></select>
</div>
</div>
<table id="stats" class="table table-striped table-hover">
<thead class="table-primary">
<tr>
<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>
</form>
<div class="modal-footer">
<div class="form-check form-switch">
<input id="free-loot" name="freeLoot" type="checkbox" class="form-check-input">
<label class="form-check-label" for="free-loot">as free loot</label>
</div>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button type="button" class="btn btn-secondary" onclick="suggestLoot()">suggest</button>
<button id="submit-btn" type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addLoot()" disabled>add</button>
</div>
</div>
</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>
</ul>
<ul class="nav">
<li><a 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.19.1/dist/bootstrap-table.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/export/bootstrap-table-export.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/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 submitLootButton = $("#submit-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() {
const player = getCurrentOption(playerInput);
$.ajax({
url: `/api/v1/party/${partyId}/loot`,
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,
},
isFreeLoot: freeLootInput.is(":checked"),
}),
type: "POST",
contentType: "application/json",
success: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
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: function (data) {
const items = data.map(function (player) {
return player.loot.map(function (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(function (left, right) { return left.concat(right); }, []);
table.bootstrapTable("load", payload);
table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading");
const options = data.map(function (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);
submitLootButton.attr("disabled", options.length === 0);
},
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
function removeLoot() {
const pieces = table.bootstrapTable("getSelections");
pieces.map(function (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: function (_) { reload(); },
error: function (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: function (data) {
const payload = data.map(function (stat) {
return {
nick: stat.nick,
job: stat.job,
isRequired: stat.isRequired,
lootCount: stat.lootCount,
lootCountBiS: stat.lootCountBiS,
lootCountTotal: stat.lootCountTotal,
};
});
stats.bootstrapTable("load", payload);
stats.bootstrapTable("uncheckAll");
stats.bootstrapTable("hideLoading");
},
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
$(function () {
setupFormClear(addLootDialog);
setupRemoveButton(table, removeButton);
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,258 @@
<!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 href="/static/favicon.ico" rel="shortcut icon">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet" type="text/css">
<link href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" rel="stylesheet" type="text/css">
<link href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" rel="stylesheet" type="text/css">
<link href="/static/styles.css" rel="stylesheet" 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-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">
<div class="modal-content">
<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>
<form 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" onkeyup="disableAddPlayerForm()">
</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"></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>
</form>
<div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button id="submit-player-btn" type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addPlayer()" disabled>add</button>
</div>
</div>
</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>
</ul>
<ul class="nav">
<li><a 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.19.1/dist/bootstrap-table.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/export/bootstrap-table-export.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/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 submitPlayerButton = $("#submit-player-btn");
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: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
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 disableAddPlayerForm() {
submitPlayerButton.attr("disabled", !nickInput.val());
}
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: function (data) {
table.bootstrapTable("load", data);
table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading");
},
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
function removePlayers() {
const players = table.bootstrapTable("getSelections");
players.map(function (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: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
});
}
$(function () {
setupFormClear(addPlayerDialog);
setupRemoveButton(table, removeButton);
loadHeader(partyId);
loadTypes("/api/v1/types/jobs", jobInput);
hideControls();
table.bootstrapTable({});
reload();
});
</script>
</body>
</html>

View File

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

View File

@ -0,0 +1,230 @@
<!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 href="/static/favicon.ico" rel="shortcut icon">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet" type="text/css">
<link href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" rel="stylesheet" type="text/css">
<link href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" rel="stylesheet" type="text/css">
<link href="/static/styles.css" rel="stylesheet" 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-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">
<div class="modal-content">
<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>
<form 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" onkeyup="disableAddUserForm()">
</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" onkeyup="disableAddUserForm()">
</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"></select>
</div>
</div>
</form>
<div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button id="submit-btn" type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addUser()" disabled>add</button>
</div>
</div>
</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>
</ul>
<ul class="nav">
<li><a 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.19.1/dist/bootstrap-table.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/export/bootstrap-table-export.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/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 submitUserButton = $("#submit-btn");
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: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
function disableAddUserForm() {
submitUserButton.attr("disabled", !(usernameInput.val() && passwordInput.val()));
}
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: function (data) {
table.bootstrapTable("load", data);
table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading");
},
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
function removeUsers() {
const users = table.bootstrapTable("getSelections");
users.map(function (user) {
$.ajax({
url: `/api/v1/party/${partyId}/users/${user.username}`,
type: "DELETE",
success: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
});
}
$(function () {
setupFormClear(addUserDialog);
setupRemoveButton(table, removeButton);
loadHeader(partyId);
loadTypes("/api/v1/types/permissions", permissionInput);
hideControls();
table.bootstrapTable({});
reload();
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -8,9 +8,10 @@
</root>
<logger name="me.arcanis.ffxivbis" level="DEBUG" />
<logger name="http" 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" />
</configuration>

View File

@ -1,59 +1,71 @@
me.arcanis.ffxivbis {
bis-provider {
# xivapi base url, string, required
xivapi-url = "https://xivapi.com"
# xivapi developer key, string, optional
#xivapi-key = "abcdef"
}
database {
# database section. Section must be declared inside
# for more detailed section descriptions refer to slick documentation
mode = "sqlite"
bis-provider {
include "item_data.json"
sqlite {
profile = "slick.jdbc.SQLiteProfile$"
db {
url = "jdbc:sqlite:ffxivbis.db"
user = "user"
password = "password"
}
numThreads = 10
# xivapi base url, string, required
xivapi-url = "https://xivapi.com"
# xivapi developer key, string, optional
#xivapi-key = "abcdef"
}
postgresql {
profile = "slick.jdbc.PostgresProfile$"
db {
url = "jdbc:postgresql://localhost/ffxivbis"
user = "ffxivbis"
password = "ffxivbis"
database {
# database section. Section must be declared inside
# for more detailed section descriptions refer to slick documentation
mode = "sqlite"
connectionPool = disabled
keepAliveConnection = yes
}
numThreads = 10
sqlite {
profile = "slick.jdbc.SQLiteProfile$"
db {
url = "jdbc:sqlite:ffxivbis.db"
#user = "user"
#password = "password"
}
numThreads = 10
}
postgresql {
profile = "slick.jdbc.PostgresProfile$"
db {
url = "jdbc:postgresql://localhost/ffxivbis"
#user = "ffxivbis"
#password = "ffxivbis"
connectionPool = disabled
keepAliveConnection = yes
}
numThreads = 10
}
}
}
settings {
# counters of Player class which will be called to sort players for loot priority
# list of strings, required
priority = [
"isRequired", "lootCountBiS", "priority", "lootCount", "lootCountTotal"
]
# general request timeout, duratin, required
request-timeout = 10s
# party in-memory storage lifetime
cache-timeout = 1m
}
settings {
# counters of Player class which will be called to sort players for loot priority
# list of strings, required
priority = [
"isRequired", "lootCountBiS", "priority", "lootCount", "lootCountTotal"
]
# general request timeout, duratin, required
request-timeout = 10s
# party in-memory storage lifetime
cache-timeout = 1m
}
web {
# address to bind, string, required
host = "127.0.0.1"
# port to bind, int, required
port = 8000
# hostname to use in docs, if not set host:port will be used
#hostname = "127.0.0.1:8000"
web {
# address to bind, string, required
host = "127.0.0.1"
# port to bind, int, required
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
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,54 @@
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 setupFormClear(dialog, reset) {
dialog.on("shown.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

@ -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,24 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>ReDoc</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

@ -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,30 +8,32 @@
*/
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.PartyService
import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.{Database, PartyService}
import me.arcanis.ffxivbis.service.database.Database
import me.arcanis.ffxivbis.storage.Migration
import scala.concurrent.ExecutionContext
import scala.jdk.CollectionConverters._
import scala.util.{Failure, Success}
class Application(context: ActorContext[Nothing])
extends AbstractBehavior[Nothing](context) with StrictLogging {
class Application(context: ActorContext[Nothing]) extends AbstractBehavior[Nothing](context) with StrictLogging {
logger.info("root supervisor started")
startApplication()
override def onMessage(msg: Nothing): Behavior[Nothing] = Behaviors.unhandled
override def onSignal: PartialFunction[Signal, Behavior[Nothing]] = {
case PostStop =>
logger.info("root supervisor stopped")
Behaviors.same
override def onSignal: PartialFunction[Signal, Behavior[Nothing]] = { case PostStop =>
logger.info("root supervisor stopped")
Behaviors.same
}
private def startApplication(): Unit = {
@ -42,14 +44,25 @@ class Application(context: ActorContext[Nothing])
implicit val executionContext: ExecutionContext = context.system.executionContext
implicit val materializer: Materializer = Materializer(context)
Migration(config)
Migration(config) match {
case Success(result) if result.success =>
val bisProvider = context.spawn(BisProvider(), "bis-provider")
val storage = context.spawn(Database(), "storage")
val party = context.spawn(PartyService(storage), "party")
val http = new RootEndpoint(context.system, party, bisProvider)
val bisProvider = context.spawn(BisProvider(), "bis-provider")
val storage = context.spawn(Database(), "storage")
val party = context.spawn(PartyService(storage), "party")
val http = new RootEndpoint(context.system, party, bisProvider)
val flow = Route.toFlow(http.route)(context.system)
Http(context.system).newServerAt(host, port).bindFlow(flow)
Http()(context.system).newServerAt(host, port).bindFlow(http.route)
case Success(result) =>
logger.error(s"migration completed with error, executed ${result.migrationsExecuted}")
result.migrations.asScala.foreach(o => logger.info(s"=> ${o.description} (${o.executionTime})"))
context.system.terminate()
case Failure(exception) =>
logger.error("exception during migration", exception)
context.system.terminate()
}
}
}

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,9 @@
package me.arcanis.ffxivbis
import akka.actor.typed.ActorSystem
import com.typesafe.config.ConfigFactory
object ffxivbis {
def main(args: Array[String]): Unit = {
val config = ConfigFactory.load()
ActorSystem[Nothing](Application(), "ffxivbis", config)
}
def main(args: Array[String]): Unit =
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,25 +8,20 @@
*/
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 authenticateBasicBCrypt[T](realm: String, authenticate: (String, String) => Future[Option[T]]): Directive1[T] = {
def challenge = HttpChallenges.basic(realm)
extractCredentials.flatMap {
@ -39,22 +34,26 @@ 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]] =
def authAdmin(partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext
): Future[Option[User]] =
authenticator(Permission.admin, partyId)(username, password)
def authGet(partyId: String)(username: String, password: String)
(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Option[String]] =
def authGet(partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext
): Future[Option[User]] =
authenticator(Permission.get, partyId)(username, password)
def authPost(partyId: String)(username: String, password: String)
(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Option[String]] =
def authPost(partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext
): Future[Option[User]] =
authenticator(Permission.post, partyId)(username, password)
private def authenticator(scope: Permission.Value, partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext
): Future[Option[User]] =
auth.get(partyId, username).map {
case Some(user) if user.verify(password) && user.verityScope(scope) => Some(user)
case _ => None
}
}

View File

@ -0,0 +1,51 @@
/*
* 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.{GetUser, Message}
import me.arcanis.ffxivbis.models.User
import java.util.concurrent.TimeUnit
import scala.concurrent.Future
trait AuthorizationProvider {
def get(partyId: String, username: String): Future[Option[User]]
}
object AuthorizationProvider {
def apply(config: Config, storage: ActorRef[Message], 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

@ -1,52 +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
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.models.{Piece, Player, PlayerId}
import scala.concurrent.{ExecutionContext, Future}
trait BiSHelper extends BisProviderHelper {
def storage: ActorRef[Message]
def addPieceBiS(playerId: PlayerId, piece: Piece)
(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(AddPieceToBis(playerId, piece.withJob(playerId.job), _))
def bis(partyId: String, playerId: Option[PlayerId])
(implicit timeout: Timeout, scheduler: Scheduler): Future[Seq[Player]] =
storage.ask(GetBiS(partyId, playerId, _))
def doModifyBiS(action: ApiAction.Value, playerId: PlayerId, piece: Piece)
(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
action match {
case ApiAction.add => addPieceBiS(playerId, piece)
case ApiAction.remove => removePieceBiS(playerId, piece)
}
def putBiS(playerId: PlayerId, link: String)
(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Unit] = {
storage.ask(RemovePiecesFromBiS(playerId, _)).flatMap { _ =>
downloadBiS(link, playerId.job).flatMap { bis =>
Future.traverse(bis.pieces)(addPieceBiS(playerId, _))
}.map(_ => ())
}
}
def removePieceBiS(playerId: PlayerId, piece: Piece)
(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(RemovePieceFromBiS(playerId, piece, _))
}

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.{Instant, ZoneId}
import java.time.format.DateTimeFormatter
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,48 +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
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.models.{Piece, Player, PlayerId, PlayerIdWithCounters}
import scala.concurrent.{ExecutionContext, Future}
trait LootHelper {
def storage: ActorRef[Message]
def addPieceLoot(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean)
(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(
AddPieceTo(playerId, piece, isFreeLoot, _))
def doModifyLoot(action: ApiAction.Value, playerId: PlayerId, piece: Piece, maybeFree: Option[Boolean])
(implicit timeout: Timeout, scheduler: Scheduler): 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")
}
def loot(partyId: String, playerId: Option[PlayerId])
(implicit timeout: Timeout, scheduler: Scheduler): 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 suggestPiece(partyId: String, piece: Piece)
(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Seq[PlayerIdWithCounters]] =
storage.ask(SuggestLoot(partyId, piece, _)).map(_.result)
}

View File

@ -1,63 +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
import akka.actor.typed.{ActorRef, Scheduler}
import akka.actor.typed.scaladsl.AskPattern.Askable
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.models.{PartyDescription, Player, PlayerId}
import scala.concurrent.{ExecutionContext, Future}
trait PlayerHelper extends BisProviderHelper {
def storage: ActorRef[Message]
def addPlayer(player: Player)
(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(ref => AddPlayer(player, ref)).map { res =>
player.link 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)
}
}.flatten
def doModifyPlayer(action: ApiAction.Value, player: Player)
(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Unit] =
action match {
case ApiAction.add => addPlayer(player)
case ApiAction.remove => removePlayer(player.playerId)
}
def getPartyDescription(partyId: String)
(implicit timeout: Timeout, scheduler: Scheduler): Future[PartyDescription] =
storage.ask(GetPartyDescription(partyId, _))
def getPlayers(partyId: String, maybePlayerId: Option[PlayerId])
(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Seq[Player]] =
maybePlayerId match {
case Some(playerId) =>
storage.ask(GetPlayer(playerId, _)).map(_.toSeq)
case None =>
storage.ask(GetParty(partyId, _)).map(_.players.values.toSeq)
}
def removePlayer(playerId: PlayerId)
(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(RemovePlayer(playerId, _))
def updateDescription(partyDescription: PartyDescription)
(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(UpdateParty(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).
@ -8,68 +8,53 @@
*/
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 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 {
class RootEndpoint(system: ActorSystem[Nothing], storage: ActorRef[Message], provider: ActorRef[BiSProviderMessage])
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.getDuration("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, timeout, scheduler)
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 =
withHttpLog {
apiRoute ~ htmlRoute ~ swagger.routes ~ swaggerUIRoute
ignoreTrailingSlash {
apiRoute ~ htmlRoute ~ swagger.routes ~ swaggerUIRoute
}
}
private def apiRoute: Route =
ignoreTrailingSlash {
pathPrefix("api") {
pathPrefix(Segment) {
case "v1" => rootApiV1Endpoint.route
case _ => reject
}
pathPrefix("api") {
pathPrefix(Segment) {
case "v1" => rootApiV1Endpoint.route
case _ => reject
}
}
private def htmlRoute: Route =
ignoreTrailingSlash {
pathPrefix("static") {
getFromResourceDirectory("static")
} ~ rootView.route
}
pathPrefix("static") {
getFromResourceDirectory("static")
} ~ rootView.route
private def swaggerUIRoute: Route =
path("swagger") {
getFromResource("swagger/index.html")
} ~ getFromResourceDirectory("swagger")
path("api-docs") {
getFromResource("html/redoc.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).
@ -18,9 +18,12 @@ import scala.io.Source
class Swagger(config: Config) extends SwaggerHttpService {
override val apiClasses: Set[Class[_]] = Set(
classOf[api.v1.BiSEndpoint], classOf[api.v1.LootEndpoint],
classOf[api.v1.PartyEndpoint], classOf[api.v1.PlayerEndpoint],
classOf[api.v1.TypesEndpoint], classOf[api.v1.UserEndpoint]
classOf[api.v1.BiSEndpoint],
classOf[api.v1.LootEndpoint],
classOf[api.v1.PartyEndpoint],
classOf[api.v1.PlayerEndpoint],
classOf[api.v1.TypesEndpoint],
classOf[api.v1.UserEndpoint]
)
override val info: Info = Info(

View File

@ -1,41 +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
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.models.User
import scala.concurrent.Future
trait UserHelper {
def storage: ActorRef[Message]
def addUser(user: User, isHashedPassword: Boolean)
(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(AddUser(user, isHashedPassword, _))
def newPartyId(implicit timeout: Timeout, scheduler: Scheduler): Future[String] =
storage.ask(GetNewPartyId)
def user(partyId: String, username: String)
(implicit timeout: Timeout, scheduler: Scheduler): Future[Option[User]] =
storage.ask(GetUser(partyId, username, _))
def users(partyId: String)
(implicit timeout: Timeout, scheduler: Scheduler): Future[Seq[User]] =
storage.ask(GetUsers(partyId, _))
def removeUser(partyId: String, username: String)
(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(DeleteUser(partyId, username, _))
}

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).
@ -19,41 +19,65 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import javax.ws.rs._
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 timeout: Timeout, scheduler: Scheduler)
extends BiSHelper with Authorization with JsonSupport {
@Path("/api/v1")
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
@PUT
@Path("party/{partyId}/bis")
@Consumes(value = Array("application/json"))
@Operation(summary = "create best in slot", description = "Create the best in slot set",
@Operation(
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"),
),
requestBody = new RequestBody(description = "player best in slot description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PlayerBiSLinkResponse])))),
requestBody = new RequestBody(
description = "player best in slot description",
required = true,
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])))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "403", description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "500", description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(
responseCode = "400",
description = "Invalid parameters were supplied",
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[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 auth", scopes = Array("post"))),
tags = Array("best in slot"),
@ -63,11 +87,10 @@ class BiSEndpoint(override val storage: ActorRef[Message],
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)
}
}
}
@ -78,23 +101,44 @@ class BiSEndpoint(override val storage: ActorRef[Message],
@GET
@Path("party/{partyId}/bis")
@Produces(value = Array("application/json"))
@Operation(summary = "get best in slot", description = "Return the best in slot items",
@Operation(
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 = "nick", in = ParameterIn.QUERY, description = "player nick name to filter", example = "Siuan Sanche"),
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 = "Best in slot",
content = Array(new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse]))
))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "403", description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "500", description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(
responseCode = "200",
description = "Best in slot",
content = Array(
new Content(
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[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 auth", scopes = Array("get"))),
tags = Array("best in slot"),
@ -106,9 +150,8 @@ class BiSEndpoint(override val storage: ActorRef[Message],
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))
}
}
}
@ -120,22 +163,39 @@ class BiSEndpoint(override val storage: ActorRef[Message],
@POST
@Path("party/{partyId}/bis")
@Consumes(value = Array("application/json"))
@Operation(summary = "modify best in slot", description = "Add or remove an item from the best in slot",
@Operation(
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"),
),
requestBody = new RequestBody(description = "action and piece description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PieceActionResponse])))),
requestBody = new RequestBody(
description = "action and piece description",
required = true,
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])))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "403", description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "500", description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(
responseCode = "400",
description = "Invalid parameters were supplied",
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[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 auth", scopes = Array("post"))),
tags = Array("best in slot"),
@ -145,11 +205,10 @@ class BiSEndpoint(override val storage: ActorRef[Message],
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).
@ -17,20 +17,20 @@ import spray.json._
trait HttpHandler extends StrictLogging { this: JsonSupport =>
implicit def exceptionHandler: ExceptionHandler = ExceptionHandler {
def exceptionHandler: ExceptionHandler = ExceptionHandler {
case ex: IllegalArgumentException =>
complete(StatusCodes.BadRequest, ErrorResponse(ex.getMessage))
complete(StatusCodes.BadRequest, ErrorModel(ex.getMessage))
case other: Exception =>
logger.error("exception during request completion", other)
complete(StatusCodes.InternalServerError, ErrorResponse("unknown server error"))
complete(StatusCodes.InternalServerError, ErrorModel("unknown server error"))
}
implicit def rejectionHandler: RejectionHandler =
def rejectionHandler: RejectionHandler =
RejectionHandler.default
.mapRejectionResponse {
case response @ HttpResponse(_, _, entity: HttpEntity.Strict, _) =>
val message = ErrorResponse(entity.data.utf8String).toJson
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).
@ -19,41 +19,67 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import javax.ws.rs._
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 with Authorization with JsonSupport with HttpHandler {
@Path("/api/v1")
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 route: Route = getLoot ~ modifyLoot ~ suggestLoot
@GET
@Path("party/{partyId}/loot")
@Produces(value = Array("application/json"))
@Operation(summary = "get loot list", description = "Return the looted items",
@Operation(
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 = "nick", in = ParameterIn.QUERY, description = "player nick name to filter", example = "Siuan Sanche"),
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 = "Loot list",
content = Array(new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse]))
))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "403", description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "500", description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(
responseCode = "200",
description = "Loot list",
content = Array(
new Content(
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[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 auth", scopes = Array("get"))),
tags = Array("loot"),
@ -65,9 +91,8 @@ class LootEndpoint(override val storage: ActorRef[Message])
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))
}
}
}
@ -78,22 +103,39 @@ class LootEndpoint(override val storage: ActorRef[Message])
@POST
@Consumes(value = Array("application/json"))
@Path("party/{partyId}/loot")
@Operation(summary = "modify loot list", description = "Add or remove an item from the loot list",
@Operation(
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"),
),
requestBody = new RequestBody(description = "action and piece description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PieceActionResponse])))),
requestBody = new RequestBody(
description = "action and piece description",
required = true,
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])))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "403", description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "500", description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(
responseCode = "400",
description = "Invalid parameters were supplied",
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[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 auth", scopes = Array("post"))),
tags = Array("loot"),
@ -103,11 +145,10 @@ class LootEndpoint(override val storage: ActorRef[Message])
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)
}
}
}
@ -119,25 +160,47 @@ class LootEndpoint(override val storage: ActorRef[Message])
@Path("party/{partyId}/loot")
@Consumes(value = Array("application/json"))
@Produces(value = Array("application/json"))
@Operation(summary = "suggest loot", description = "Suggest loot piece to party",
@Operation(
summary = "suggest loot",
description = "Suggest loot piece to party",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
requestBody = new RequestBody(description = "piece description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PieceResponse])))),
requestBody = new RequestBody(
description = "piece description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PieceModel])))
),
responses = Array(
new ApiResponse(responseCode = "200", description = "Players with counters ordered by priority to get this item",
content = Array(new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerIdWithCountersResponse])),
))),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "403", description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "500", description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(
responseCode = "200",
description = "Players with counters ordered by priority to get this item",
content = Array(
new Content(
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[ErrorModel])))
),
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 auth", scopes = Array("get"))),
tags = Array("loot"),
@ -147,10 +210,9 @@ class LootEndpoint(override val storage: ActorRef[Message])
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).
@ -19,37 +19,59 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import javax.ws.rs._
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 timeout: Timeout, scheduler: Scheduler)
extends PlayerHelper with Authorization with JsonSupport with HttpHandler {
@Path("/api/v1")
class PartyEndpoint(
override val storage: ActorRef[Message],
override val provider: ActorRef[BiSProviderMessage],
override val auth: AuthorizationProvider
)(implicit
timeout: Timeout,
scheduler: Scheduler
) extends PlayerHelper
with Authorization
with JsonSupport
with HttpHandler {
def route: Route = getPartyDescription ~ modifyPartyDescription
@GET
@Path("party/{partyId}/description")
@Produces(value = Array("application/json"))
@Operation(summary = "get party description", description = "Return the party description",
@Operation(
summary = "get party description",
description = "Return the party description",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
responses = Array(
new ApiResponse(responseCode = "200", description = "Party description",
content = Array(new Content(schema = new Schema(implementation = classOf[PartyDescriptionResponse])))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "403", description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "500", description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(
responseCode = "200",
description = "Party description",
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[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 auth", scopes = Array("get"))),
tags = Array("party"),
@ -59,9 +81,8 @@ class PartyEndpoint(override val storage: ActorRef[Message],
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))
}
}
}
@ -71,22 +92,39 @@ class PartyEndpoint(override val storage: ActorRef[Message],
@POST
@Consumes(value = Array("application/json"))
@Path("party/{partyId}/description")
@Operation(summary = "modify party description", description = "Edit party description",
@Operation(
summary = "modify party description",
description = "Edit party description",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
requestBody = new RequestBody(description = "new party description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PartyDescriptionResponse])))),
requestBody = new RequestBody(
description = "new party description",
required = true,
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])))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "403", description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "500", description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(
responseCode = "400",
description = "Invalid parameters were supplied",
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[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 auth", scopes = Array("post"))),
tags = Array("party"),
@ -96,11 +134,10 @@ class PartyEndpoint(override val storage: ActorRef[Message],
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).
@ -19,42 +19,71 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import javax.ws.rs._
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 timeout: Timeout, scheduler: Scheduler)
extends PlayerHelper with Authorization with JsonSupport with HttpHandler {
@Path("/api/v1")
class PlayerEndpoint(
override val storage: ActorRef[Message],
override val provider: ActorRef[BiSProviderMessage],
override val auth: AuthorizationProvider
)(implicit
timeout: Timeout,
scheduler: Scheduler
) extends PlayerHelper
with Authorization
with JsonSupport
with HttpHandler {
def route: Route = getParty ~ modifyParty
def route: Route = getParty ~ getPartyStats ~ modifyParty
@GET
@Path("party/{partyId}")
@Produces(value = Array("application/json"))
@Operation(summary = "get party", description = "Return the players who belong to the party",
@Operation(
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 = "nick", in = ParameterIn.QUERY, description = "player nick name to filter", example = "Siuan Sanche"),
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 = "Players list",
content = Array(new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse])),
))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "403", description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "500", description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(
responseCode = "200",
description = "Players list",
content = Array(
new Content(
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[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 auth", scopes = Array("get"))),
tags = Array("party"),
@ -66,9 +95,69 @@ class PlayerEndpoint(override val storage: ActorRef[Message],
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 = "abcdefgh"),
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 auth", 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))))
}
}
}
@ -79,22 +168,39 @@ class PlayerEndpoint(override val storage: ActorRef[Message],
@POST
@Path("party/{partyId}")
@Consumes(value = Array("application/json"))
@Operation(summary = "modify party", description = "Add or remove a player from party list",
@Operation(
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"),
),
requestBody = new RequestBody(description = "player description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PlayerActionResponse])))),
requestBody = new RequestBody(
description = "player description",
required = true,
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])))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "403", description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "500", description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(
responseCode = "400",
description = "Invalid parameters were supplied",
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[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 auth", scopes = Array("post"))),
tags = Array("party"),
@ -103,11 +209,12 @@ class PlayerEndpoint(override val storage: ActorRef[Message],
path("party" / Segment) { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
entity(as[PlayerActionResponse]) { 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
post {
entity(as[PlayerActionModel]) { action =>
val player = action.playerId.toPlayer.copy(partyId = partyId)
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,20 +13,27 @@ 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 timeout: Timeout, scheduler: Scheduler)
extends JsonSupport with HttpHandler {
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 typesEndpoint = new TypesEndpoint(config)
private val userEndpoint = new UserEndpoint(storage)
private val userEndpoint = new UserEndpoint(storage, auth)
def route: Route =
handleExceptions(exceptionHandler) {

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,50 +11,101 @@ 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 javax.ws.rs._
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")
@Path("/api/v1")
class TypesEndpoint(config: Config) extends JsonSupport {
def route: Route = getJobs ~ getPermissions ~ getPieces ~ getPieceTypes ~ getPriority
def route: Route = getAllJobs ~ getJobs ~ getPermissions ~ getPieces ~ getPieceTypes ~ getPriority
@GET
@Path("types/jobs")
@Path("types/jobs/all")
@Produces(value = Array("application/json"))
@Operation(summary = "jobs list", description = "Returns the available jobs",
@Operation(
summary = "full jobs list",
description = "Returns the available jobs including any job",
responses = Array(
new ApiResponse(responseCode = "200", description = "List of available jobs",
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[ErrorResponse])))),
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 getJobs: Route =
path("types" / "jobs") {
def getAllJobs: Route =
path("types" / "jobs" / "all") {
get {
complete(Job.availableWithAnyJob.map(_.toString))
}
}
@GET
@Path("types/jobs")
@Produces(value = Array("application/json"))
@Operation(
summary = "jobs list",
description = "Returns the available jobs",
responses = Array(
new ApiResponse(
responseCode = "200",
description = "List of available jobs",
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 getJobs: Route =
path("types" / "jobs") {
get {
complete(Job.available.map(_.toString))
}
}
@GET
@Path("types/permissions")
@Produces(value = Array("application/json"))
@Operation(summary = "permissions list", description = "Returns the available permissions",
@Operation(
summary = "permissions list",
description = "Returns the available permissions",
responses = Array(
new ApiResponse(responseCode = "200", description = "List of available permissions",
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[ErrorResponse])))),
new ApiResponse(
responseCode = "200",
description = "List of available permissions",
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"),
)
@ -68,14 +119,24 @@ class TypesEndpoint(config: Config) extends JsonSupport {
@GET
@Path("types/pieces")
@Produces(value = Array("application/json"))
@Operation(summary = "pieces list", description = "Returns the available pieces",
@Operation(
summary = "pieces list",
description = "Returns the available pieces",
responses = Array(
new ApiResponse(responseCode = "200", description = "List of available pieces",
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[ErrorResponse])))),
new ApiResponse(
responseCode = "200",
description = "List of available pieces",
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"),
)
@ -89,14 +150,24 @@ class TypesEndpoint(config: Config) extends JsonSupport {
@GET
@Path("types/pieces/types")
@Produces(value = Array("application/json"))
@Operation(summary = "piece types list", description = "Returns the available piece types",
@Operation(
summary = "piece types list",
description = "Returns the available piece types",
responses = Array(
new ApiResponse(responseCode = "200", description = "List of available piece types",
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[ErrorResponse])))),
new ApiResponse(
responseCode = "200",
description = "List of available piece types",
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"),
)
@ -110,14 +181,24 @@ class TypesEndpoint(config: Config) extends JsonSupport {
@GET
@Path("types/priority")
@Produces(value = Array("application/json"))
@Operation(summary = "priority list", description = "Returns the current priority list",
@Operation(
summary = "priority list",
description = "Returns the current priority list",
responses = Array(
new ApiResponse(responseCode = "200", description = "Priority order",
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[ErrorResponse])))),
new ApiResponse(
responseCode = "200",
description = "Priority order",
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"),
)

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).
@ -19,35 +19,57 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import javax.ws.rs._
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 with Authorization with JsonSupport {
@Path("/api/v1")
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 route: Route = createParty ~ createUser ~ deleteUser ~ getUsers ~ getUsersCurrent
@PUT
@Path("party")
@Consumes(value = Array("application/json"))
@Operation(summary = "create new party", description = "Create new party with specified ID",
requestBody = new RequestBody(description = "party administrator description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[UserResponse])))),
@Operation(
summary = "create new party",
description = "Create new party with specified ID",
requestBody = new RequestBody(
description = "party administrator description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[UserModel])))
),
responses = Array(
new ApiResponse(responseCode = "200", description = "Party has been created"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "406", description = "Party with the specified ID already exists",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "500", description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
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[ErrorModel])))
),
new ApiResponse(
responseCode = "406",
description = "Party with the specified ID already exists",
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])))
),
),
tags = Array("party"),
)
@ -55,15 +77,12 @@ class UserEndpoint(override val storage: ActorRef[Message])
path("party") {
extractExecutionContext { implicit executionContext =>
put {
entity(as[UserResponse]) { user =>
onComplete(newPartyId) {
case Success(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
}
case Failure(exception) => throw exception
entity(as[UserModel]) { user =>
onSuccess(newPartyId) { partyId =>
val admin = user.toUser.copy(partyId = partyId, permission = Permission.admin)
onSuccess(addUser(admin, isHashedPassword = false)) {
complete(PartyIdModel(partyId))
}
}
}
}
@ -73,22 +92,39 @@ class UserEndpoint(override val storage: ActorRef[Message])
@POST
@Path("party/{partyId}/users")
@Consumes(value = Array("application/json"))
@Operation(summary = "create new user", description = "Add an user to the specified party",
@Operation(
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"),
),
requestBody = new RequestBody(description = "user description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[UserResponse])))),
requestBody = new RequestBody(
description = "user description",
required = true,
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])))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "403", description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "500", description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(
responseCode = "400",
description = "Invalid parameters were supplied",
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[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 auth", scopes = Array("admin"))),
tags = Array("users"),
@ -98,11 +134,10 @@ class UserEndpoint(override val storage: ActorRef[Message])
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)
}
}
}
@ -112,19 +147,30 @@ class UserEndpoint(override val storage: ActorRef[Message])
@DELETE
@Path("party/{partyId}/users/{username}")
@Operation(summary = "remove user", description = "Remove an user from the specified party",
@Operation(
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 = "username", in = ParameterIn.PATH, description = "username to remove", example = "siuan"),
),
responses = Array(
new ApiResponse(responseCode = "202", description = "User has been removed"),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "403", description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "500", description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
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 auth", scopes = Array("admin"))),
tags = Array("users"),
@ -134,9 +180,8 @@ class UserEndpoint(override val storage: ActorRef[Message])
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)
}
}
}
@ -146,23 +191,39 @@ class UserEndpoint(override val storage: ActorRef[Message])
@GET
@Path("party/{partyId}/users")
@Produces(value = Array("application/json"))
@Operation(summary = "get users", description = "Return the list of users belong to party",
@Operation(
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"),
),
responses = Array(
new ApiResponse(responseCode = "200", description = "Users list",
content = Array(new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[UserResponse])),
))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "403", description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(responseCode = "500", description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))),
new ApiResponse(
responseCode = "200",
description = "Users list",
content = Array(
new Content(
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[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 auth", scopes = Array("admin"))),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("users"),
)
def getUsers: Route =
@ -170,12 +231,56 @@ class UserEndpoint(override val storage: ActorRef[Message])
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 = "abcdefgh"),
),
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 auth", 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).

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,5 +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 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,12 +8,12 @@
*/
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] =
@ -38,17 +38,19 @@ 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 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 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[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 userFormat: RootJsonFormat[UserModel] = jsonFormat4(UserModel.apply)
}

View File

@ -1,20 +1,29 @@
/*
* 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) {
@Schema(description = "is loot free for all", required = true) isFreeLoot: Boolean
) {
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,15 +11,16 @@ 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,
@Schema(description = "party name") partyAlias: Option[String]) {
case class PartyDescriptionModel(
@Schema(description = "party id", required = true, example = "abcdefgh") partyId: String,
@Schema(description = "party name") partyAlias: Option[String]
) {
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

@ -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,5 +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 PartyIdModel(@Schema(description = "party id", required = true, example = "abcdefgh") 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,8 +10,14 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
case class PieceActionResponse(
@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])
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: 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,16 +11,17 @@ 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) {
@Schema(description = "piece name", required = true, example = "body") piece: String
) {
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,6 +10,13 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
case class PlayerActionResponse(
@Schema(description = "action to perform", required = true, `type` = "string", allowableValues = Array("add", "remove"), example = "add") action: ApiAction.Value,
@Schema(description = "player description", required = true) playerId: PlayerResponse)
case class PlayerActionModel(
@Schema(
description = "action to perform",
required = true,
`type` = "string",
allowableValues = Array("add", "remove"),
example = "add"
) action: ApiAction.Value,
@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,6 +10,11 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
case class PlayerBiSLinkResponse(
@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)
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: PlayerIdModel
)

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,18 @@ 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(
case class PlayerIdModel(
@Schema(description = "unique party ID. Required in responses", example = "abcdefgh") 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) {
@Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String
) {
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,21 +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(
case class PlayerIdWithCountersModel(
@Schema(description = "unique party ID", required = true, example = "abcdefgh") 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)
@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,
@ -34,5 +35,6 @@ object PlayerIdWithCountersResponse {
playerIdWithCounters.bisCountTotal,
playerIdWithCounters.lootCount,
playerIdWithCounters.lootCountBiS,
playerIdWithCounters.lootCountTotal)
playerIdWithCounters.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).
@ -11,27 +11,43 @@ 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(
case class PlayerModel(
@Schema(description = "unique party ID", required = true, example = "abcdefgh") 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") lootCountBiS: Option[Int],
@Schema(description = "total count of looted pieces", `type` = "number") lootCountTotal: Option[Int],
) {
def toPlayer: Player =
Player(-1, partyId, Job.withName(job), nick,
Player(
-1,
partyId,
Job.withName(job),
nick,
BiS(bis.getOrElse(Seq.empty).map(_.toPiece)),
loot.getOrElse(Seq.empty).map(_.toLoot),
link, priority.getOrElse(0))
link,
priority.getOrElse(0)
)
}
object PlayerResponse {
object PlayerModel {
def fromPlayer(player: Player): PlayerResponse =
PlayerResponse(player.partyId, player.job.toString, player.nick,
Some(player.bis.pieces.map(PieceResponse.fromPiece)),
Some(player.loot.map(LootResponse.fromLoot)),
player.link, Some(player.priority))
def fromPlayer(player: Player): PlayerModel =
PlayerModel(
player.partyId,
player.job.toString,
player.nick,
Some(player.bis.pieces.map(PieceModel.fromPiece)),
Some(player.loot.map(LootModel.fromLoot)),
player.link,
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).
@ -11,18 +11,24 @@ 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(
case class UserModel(
@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) {
@Schema(
description = "user permission",
defaultValue = "get",
`type` = "string",
allowableValues = Array("get", "post", "admin")
) permission: Option[Permission.Value] = None
) {
def toUser: User =
User(partyId, username, password, permission.getOrElse(Permission.get))
}
object UserResponse {
object UserModel {
def fromUser(user: User): UserResponse =
UserResponse(user.partyId, user.username, "", Some(user.permission))
def fromUser(user: User): UserModel =
UserModel(user.partyId, user.username, "", Some(user.permission))
}

View File

@ -0,0 +1,50 @@
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._
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId}
import scala.concurrent.{ExecutionContext, Future}
trait BiSHelper extends BisProviderHelper {
def storage: ActorRef[Message]
def addPieceBiS(playerId: PlayerId, piece: Piece)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(AddPieceToBis(playerId, piece.withJob(playerId.job), _))
def bis(partyId: String, playerId: Option[PlayerId])(implicit
timeout: Timeout,
scheduler: Scheduler
): Future[Seq[Player]] =
storage.ask(GetBiS(partyId, playerId, _))
def doModifyBiS(action: ApiAction.Value, playerId: PlayerId, piece: Piece)(implicit
timeout: Timeout,
scheduler: Scheduler
): Future[Unit] =
action match {
case ApiAction.add => addPieceBiS(playerId, piece)
case ApiAction.remove => removePieceBiS(playerId, piece)
}
def putBiS(playerId: PlayerId, link: String)(implicit
executionContext: ExecutionContext,
timeout: Timeout,
scheduler: Scheduler
): Future[Unit] =
storage.ask(RemovePiecesFromBiS(playerId, _)).flatMap { _ =>
downloadBiS(link, playerId.job)
.flatMap { bis =>
Future.traverse(bis.pieces)(addPieceBiS(playerId, _))
}
.map(_ => ())
}
def removePieceBiS(playerId: PlayerId, piece: Piece)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(RemovePieceFromBiS(playerId, piece, _))
}

View File

@ -1,15 +1,7 @@
/*
* 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
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.models.{BiS, Job}
@ -20,7 +12,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.Job)(implicit timeout: Timeout, scheduler: Scheduler): Future[BiS] =
provider.ask(DownloadBiS(link, job, _))
}

View File

@ -0,0 +1,47 @@
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._
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId, PlayerIdWithCounters}
import scala.concurrent.{ExecutionContext, Future}
trait LootHelper {
def storage: ActorRef[Message]
def addPieceLoot(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean)(implicit
timeout: Timeout,
scheduler: Scheduler
): Future[Unit] =
storage.ask(AddPieceTo(playerId, piece, isFreeLoot, _))
def doModifyLoot(action: ApiAction.Value, playerId: PlayerId, piece: Piece, maybeFree: Option[Boolean])(implicit
timeout: Timeout,
scheduler: Scheduler
): 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")
}
def loot(partyId: String, playerId: Option[PlayerId])(implicit
timeout: Timeout,
scheduler: Scheduler
): 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 suggestPiece(partyId: String, piece: Piece)(implicit
executionContext: ExecutionContext,
timeout: Timeout,
scheduler: Scheduler
): Future[Seq[PlayerIdWithCounters]] =
storage.ask(SuggestLoot(partyId, piece, _)).map(_.result)
}

View File

@ -0,0 +1,66 @@
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._
import me.arcanis.ffxivbis.models.{PartyDescription, Player, PlayerId}
import scala.concurrent.{ExecutionContext, Future}
trait PlayerHelper extends BisProviderHelper {
def storage: ActorRef[Message]
def addPlayer(
player: Player
)(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage
.ask(ref => AddPlayer(player, ref))
.map { res =>
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)
}
}
.flatten
def doModifyPlayer(action: ApiAction.Value, player: Player)(implicit
executionContext: ExecutionContext,
timeout: Timeout,
scheduler: Scheduler
): Future[Unit] =
action match {
case ApiAction.add => addPlayer(player)
case ApiAction.remove => removePlayer(player.playerId)
}
def getPartyDescription(partyId: String)(implicit timeout: Timeout, scheduler: Scheduler): Future[PartyDescription] =
storage.ask(GetPartyDescription(partyId, _))
def getPlayers(partyId: String, maybePlayerId: Option[PlayerId])(implicit
executionContext: ExecutionContext,
timeout: Timeout,
scheduler: Scheduler
): Future[Seq[Player]] =
maybePlayerId match {
case Some(playerId) =>
storage.ask(GetPlayer(playerId, _)).map(_.toSeq)
case None =>
storage.ask(GetParty(partyId, _)).map(_.players.values.toSeq)
}
def removePlayer(playerId: PlayerId)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(RemovePlayer(playerId, _))
def updateDescription(
partyDescription: PartyDescription
)(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(UpdateParty(partyDescription, _))
}

View File

@ -0,0 +1,29 @@
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._
import me.arcanis.ffxivbis.models.User
import scala.concurrent.Future
trait UserHelper {
def storage: ActorRef[Message]
def addUser(user: User, isHashedPassword: Boolean)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(AddUser(user, isHashedPassword, _))
def newPartyId(implicit timeout: Timeout, scheduler: Scheduler): Future[String] =
storage.ask(GetNewPartyId)
def user(partyId: String, username: String)(implicit timeout: Timeout, scheduler: Scheduler): Future[Option[User]] =
storage.ask(GetUser(partyId, username, _))
def users(partyId: String)(implicit timeout: Timeout, scheduler: Scheduler): Future[Seq[User]] =
storage.ask(GetUsers(partyId, _))
def removeUser(partyId: String, username: String)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(DeleteUser(partyId, username, _))
}

View File

@ -1,71 +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,155 +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).map(_ => ())
case _ => Future.failed(new Error(s"Could not construct piece from `$piece ($pieceType)`"))
}
PlayerId(partyId, player) match {
case Some(playerId) => (maybePiece, maybePieceType, action, maybeLink) 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).map(_ => ())
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,94 +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,140 +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"),
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,147 +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).map(_ => ())
case (Some(piece), "remove") => removePieceLoot(playerId, piece).map(_ => ())
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,137 +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)).map(_ => ())
case ("remove", Some(playerId)) => removePlayer(playerId).map(_ => ())
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,33 +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 {
private val basePartyView = new BasePartyView(storage, provider)
private val indexView = new IndexView(storage, provider)
def route: Route = getBiS ~ getIndex ~ getLoot ~ getParty ~ getUsers
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 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)
) {
getFromResource("html/bis.html")
}
}
}
}
def route: Route =
basePartyView.route ~ indexView.route ~
biSView.route ~ lootView.route ~ lootSuggestView.route ~ playerView.route ~ userView.route
}
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,23 +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,130 +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).map(_ => ())
case _ => Future.failed(new Error(s"Could not construct permission/password from `$maybePermission`/`$maybePassword`"))
}
case "remove" => removeUser(partyId, username).map(_ => ())
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,4 +13,7 @@ import me.arcanis.ffxivbis.models.{BiS, Job}
sealed trait BiSProviderMessage
case class DownloadBiS(link: String, job: Job.Job, replyTo: ActorRef[BiS]) extends BiSProviderMessage
case class DownloadBiS(link: String, job: 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,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

View File

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

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).
@ -16,18 +16,22 @@ case class BiS(pieces: Seq[Piece]) {
}
def upgrades: Map[PieceUpgrade, Int] =
pieces.groupBy(_.upgrade).foldLeft(Map.empty[PieceUpgrade, Int]) {
case (acc, (Some(k), v)) => acc + (k -> v.size)
case (acc, _) => acc
} withDefaultValue 0
pieces
.groupBy(_.upgrade)
.foldLeft(Map.empty[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 = {
def comparePieces(left: Seq[Piece], right: Seq[Piece]): Boolean =
left.groupBy(identity).view.mapValues(_.size).forall {
case (key, count) => right.count(_.strictEqual(key)) == count
left.groupBy(identity).view.mapValues(_.size).forall { case (key, count) =>
right.count(_.strictEqual(key)) == count
}
obj 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).
@ -29,6 +29,7 @@ object Job {
sealed trait Job extends Equals {
def leftSide: LeftSide
def rightSide: RightSide
// conversion to string to avoid recursion
@ -62,6 +63,10 @@ object Job {
val leftSide: LeftSide = BodyMnks
val rightSide: RightSide = AccessoriesStr
}
trait Drgs extends Job {
val leftSide: LeftSide = BodyDrgs
val rightSide: RightSide = AccessoriesStr
}
trait Tanks extends Job {
val leftSide: LeftSide = BodyTanks
val rightSide: RightSide = AccessoriesVit
@ -79,12 +84,11 @@ object Job {
case object WHM extends Healers
case object SCH extends Healers
case object AST extends Healers
case object SGE extends Healers
case object MNK extends Mnks
case object DRG extends Job {
val leftSide: LeftSide = BodyDrgs
val rightSide: RightSide = AccessoriesStr
}
case object DRG extends Drgs
case object RPR extends Drgs
case object NIN extends Job {
val leftSide: LeftSide = BodyNins
val rightSide: RightSide = AccessoriesDex
@ -100,7 +104,7 @@ object Job {
case object RDM extends Casters
lazy val available: Seq[Job] =
Seq(PLD, WAR, DRK, GNB, WHM, SCH, AST, MNK, DRG, NIN, SAM, BRD, MCH, DNC, BLM, SMN, RDM)
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)
def withName(job: String): Job.Job =

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).
@ -17,10 +17,13 @@ import scala.util.Random
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")
@ -37,15 +40,19 @@ case class Party(partyDescription: PartyDescription, rules: Seq[String], players
object Party {
def apply(party: PartyDescription, config: Config,
players: Map[Long, Player], bis: Seq[Loot], loot: Seq[Loot]): Party = {
def apply(
party: PartyDescription,
config: Config,
players: Map[Long, Player],
bis: Seq[Loot],
loot: Seq[Loot]
): Party = {
val bisByPlayer = bis.groupBy(_.playerId).view.mapValues(piece => BiS(piece.map(_.piece)))
val lootByPlayer = loot.groupBy(_.playerId).view
val playersWithItems = players.foldLeft(Map.empty[PlayerId, Player]) {
case (acc, (playerId, player)) =>
acc + (player.playerId -> player
.withBiS(bisByPlayer.get(playerId))
.withLoot(lootByPlayer.getOrElse(playerId, Seq.empty)))
val playersWithItems = players.foldLeft(Map.empty[PlayerId, Player]) { case (acc, (playerId, player)) =>
acc + (player.playerId -> player
.withBiS(bisByPlayer.get(playerId))
.withLoot(lootByPlayer.getOrElse(playerId, Seq.empty)))
}
Party(party, getRules(config), playersWithItems)
}

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).
@ -16,4 +16,4 @@ case class PartyDescription(partyId: String, partyAlias: Option[String]) {
object PartyDescription {
def empty(partyId: String): PartyDescription = PartyDescription(partyId, None)
}
}

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,20 @@ package me.arcanis.ffxivbis.models
sealed trait Piece extends Equals {
def pieceType: PieceType.PieceType
def job: Job.Job
def piece: String
def withJob(other: Job.Job): Piece
def upgrade: Option[PieceUpgrade] = this match {
case _ if pieceType != PieceType.Tome => None
case _: Waist => Some(AccessoryUpgrade)
case _: PieceAccessory => Some(AccessoryUpgrade)
case _: PieceBody => Some(BodyUpgrade)
case _: PieceWeapon => Some(WeaponUpgrade)
def upgrade: Option[PieceUpgrade] = {
val isTome = pieceType == PieceType.Tome
Some(this).collect {
case _: PieceAccessory if isTome => AccessoryUpgrade
case _: PieceBody if isTome => BodyUpgrade
case _: PieceWeapon if isTome => WeaponUpgrade
}
}
// used for ring comparison
@ -54,10 +57,6 @@ case class Hands(override val pieceType: PieceType.PieceType, override val job:
val piece: String = "hands"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Waist(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody {
val piece: String = "waist"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Legs(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody {
val piece: String = "legs"
def withJob(other: Job.Job): Piece = copy(job = other)
@ -79,8 +78,11 @@ case class Wrist(override val pieceType: PieceType.PieceType, override val job:
val piece: String = "wrist"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Ring(override val pieceType: PieceType.PieceType, override val job: Job.Job, override val piece: String = "ring")
extends PieceAccessory {
case class Ring(
override val pieceType: PieceType.PieceType,
override val job: Job.Job,
override val piece: String = "ring"
) extends PieceAccessory {
def withJob(other: Job.Job): Piece = copy(job = other)
override def equals(obj: Any): Boolean = obj match {
@ -111,7 +113,6 @@ object Piece {
case "head" => Head(pieceType, job)
case "body" => Body(pieceType, job)
case "hands" => Hands(pieceType, job)
case "waist" => Waist(pieceType, job)
case "legs" => Legs(pieceType, job)
case "feet" => Feet(pieceType, job)
case "ears" => Ears(pieceType, job)
@ -124,8 +125,20 @@ object Piece {
case other => throw new Error(s"Unknown item type $other")
}
lazy val available: Seq[String] = Seq("weapon",
"head", "body", "hands", "waist", "legs", "feet",
"ears", "neck", "wrist", "left ring", "right ring",
"accessory upgrade", "body upgrade", "weapon upgrade")
lazy val available: Seq[String] = Seq(
"weapon",
"head",
"body",
"hands",
"legs",
"feet",
"ears",
"neck",
"wrist",
"left ring",
"right ring",
"accessory upgrade",
"body upgrade",
"weapon upgrade"
)
}

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.models
object PieceType {
@ -7,9 +15,10 @@ object PieceType {
case object Crafted extends PieceType
case object Tome extends PieceType
case object Savage extends PieceType
case object Artifact extends PieceType
lazy val available: Seq[PieceType] =
Seq(Crafted, Tome, Savage)
Seq(Crafted, Tome, Savage, 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).
@ -8,47 +8,63 @@
*/
package me.arcanis.ffxivbis.models
case class Player(id: Long,
partyId: String,
job: Job.Job,
nick: String,
bis: BiS,
loot: Seq[Loot],
link: Option[String] = None,
priority: Int = 0) {
case class Player(
id: Long,
partyId: String,
job: Job.Job,
nick: String,
bis: BiS,
loot: Seq[Loot],
link: Option[String] = None,
priority: Int = 0
) {
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, job, nick, isRequired(piece), priority,
bisCountTotal(piece), lootCount(piece),
lootCountBiS(piece), lootCountTotal(piece))
partyId,
job,
nick,
isRequired(piece),
priority,
bisCountTotal,
lootCount(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)
}
def isRequired(piece: Option[Piece]): Boolean = {
def isRequired(piece: Option[Piece]): Boolean =
piece match {
case None => false
case Some(p) if !bis.hasPiece(p) => false
case Some(p: 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).
@ -14,6 +14,7 @@ import scala.util.matching.Regex
trait PlayerIdBase {
def job: Job.Job
def nick: String
override def toString: String = s"$nick ($job)"

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,21 +8,24 @@
*/
package me.arcanis.ffxivbis.models
case class PlayerIdWithCounters(partyId: String,
job: Job.Job,
nick: String,
isRequired: Boolean,
priority: Int,
bisCountTotal: Int,
lootCount: Int,
lootCountBiS: Int,
lootCountTotal: Int)
extends PlayerIdBase {
case class PlayerIdWithCounters(
partyId: String,
job: Job.Job,
nick: String,
isRequired: Boolean,
priority: Int,
bisCountTotal: Int,
lootCount: Int,
lootCountBiS: Int,
lootCountTotal: Int
) extends PlayerIdBase {
import 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(
@ -31,7 +34,8 @@ case class PlayerIdWithCounters(partyId: String,
"bisCountTotal" -> bisCountTotal, // the more pieces in bis the more priority
"lootCount" -> -lootCount, // the less loot got the more priority
"lootCountBiS" -> -lootCountBiS, // the less bis pieces looted the more priority
"lootCountTotal" -> -lootCountTotal) withDefaultValue 0 // the less pieces looted the more priority
"lootCountTotal" -> -lootCountTotal
).withDefaultValue(0) // the less pieces looted the more priority
private def withCounters(orderBy: Seq[String]): PlayerCountersComparator =
PlayerCountersComparator(orderBy.map(counters): _*)
@ -40,6 +44,7 @@ case class PlayerIdWithCounters(partyId: String,
object PlayerIdWithCounters {
private case class PlayerCountersComparator(values: Int*) {
def >(that: PlayerCountersComparator): Boolean = {
@scala.annotation.tailrec
def compareLists(left: List[Int], right: List[Int]): 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).
@ -14,13 +14,13 @@ object Permission extends Enumeration {
val get, post, admin = Value
}
case class User(partyId: String,
username: String,
password: String,
permission: Permission.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,18 +9,19 @@
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])
extends AbstractBehavior[Message](context) with StrictLogging {
extends AbstractBehavior[Message](context)
with StrictLogging {
import me.arcanis.ffxivbis.utils.Implicits._
private val cacheTimeout: FiniteDuration =

View File

@ -1,44 +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.service.bis
import akka.http.scaladsl.model.Uri
import me.arcanis.ffxivbis.models.Job
import spray.json.{JsNumber, JsObject, JsString, deserializationError}
import scala.concurrent.{ExecutionContext, Future}
object Ariyala {
def idParser(job: Job.Job, js: JsObject)
(implicit executionContext: ExecutionContext): Future[Map[String, Long]] =
Future {
val apiJob = js.fields.get("content") match {
case Some(JsString(value)) => value
case other => throw deserializationError(s"Invalid job name $other")
}
js.fields.get("datasets") match {
case Some(datasets: JsObject) =>
val fields = datasets.fields
fields.getOrElse(apiJob, fields(job.toString)).asJsObject
.fields("normal").asJsObject
.fields("items").asJsObject
.fields.foldLeft(Map.empty[String, Long]) {
case (acc, (key, JsNumber(id))) => BisProvider.remapKey(key).map(k => acc + (k -> id.toLong)).getOrElse(acc)
case (acc, _) => acc
}
case other => throw deserializationError(s"Invalid json $other")
}
}
def uri(root: Uri, id: String): Uri =
root
.withPath(Uri.Path / "store.app")
.withQuery(Uri.Query(Map("identifier" -> id)))
}

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,50 +8,55 @@
*/
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.models.{BiS, Job, Piece, PieceType}
import me.arcanis.ffxivbis.service.bis.parser.Parser
import me.arcanis.ffxivbis.service.bis.parser.impl.{Ariyala, Etro}
import spray.json._
import java.nio.file.Paths
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success}
class BisProvider(context: ActorContext[BiSProviderMessage])
extends AbstractBehavior[BiSProviderMessage](context) with XivApi with StrictLogging {
extends AbstractBehavior[BiSProviderMessage](context)
with XivApi
with StrictLogging {
override def system: ClassicActorSystemProvider = context.system
override def onMessage(msg: BiSProviderMessage): Behavior[BiSProviderMessage] =
msg match {
case DownloadBiS(link, job, client) =>
get(link, job).map(BiS(_)).foreach(client ! _)
get(link, job).onComplete {
case Success(items) => client ! BiS(items)
case Failure(exception) =>
logger.error("received exception while getting items", exception)
}
Behaviors.same
}
override def onSignal: PartialFunction[Signal, Behavior[BiSProviderMessage]] = {
case PostStop =>
shutdown()
Behaviors.same
override def onSignal: PartialFunction[Signal, Behavior[BiSProviderMessage]] = { case PostStop =>
shutdown()
Behaviors.same
}
private def get(link: String, job: Job.Job): Future[Seq[Piece]] = {
val url = Uri(link)
val id = Paths.get(link).normalize.getFileName.toString
private def get(link: String, job: Job.Job): Future[Seq[Piece]] =
try {
val url = Uri(link)
val id = Paths.get(link).normalize.getFileName.toString
val (idParser, uri) =
if (url.authority.host.address().contains("etro")) {
(Etro.idParser(_, _), Etro.uri(url, id))
} else {
(Ariyala.idParser(_, _), Ariyala.uri(url, id))
}
sendRequest(uri, BisProvider.parseBisJsonToPieces(job, idParser, getPieceType))
}
val parser = if (url.authority.host.address().contains("etro")) Etro else Ariyala
val uri = parser.uri(url, id)
sendRequest(uri, BisProvider.parseBisJsonToPieces(job, parser, getPieceType))
} catch {
case exception: Exception => Future.failed(exception)
}
}
object BisProvider {
@ -59,16 +64,19 @@ object BisProvider {
def apply(): Behavior[BiSProviderMessage] =
Behaviors.setup[BiSProviderMessage](context => new BisProvider(context))
private def parseBisJsonToPieces(job: Job.Job,
idParser: (Job.Job, JsObject) => Future[Map[String, Long]],
pieceTypes: Seq[Long] => Future[Map[Long, PieceType.PieceType]])
(js: JsObject)
(implicit executionContext: ExecutionContext): Future[Seq[Piece]] =
idParser(job, js).flatMap { pieces =>
private def parseBisJsonToPieces(
job: Job.Job,
idParser: Parser,
pieceTypes: Seq[Long] => Future[Map[Long, PieceType.PieceType]]
)(js: JsObject)(implicit executionContext: ExecutionContext): Future[Seq[Piece]] =
idParser.parse(job, js).flatMap { pieces =>
pieceTypes(pieces.values.toSeq).map { types =>
pieces.view.mapValues(types).map {
case (piece, pieceType) => Piece(piece, pieceType, job)
}.toSeq
pieces.view
.mapValues(types)
.map { case (piece, pieceType) =>
Piece(piece, pieceType, job)
}
.toSeq
}
}
@ -77,7 +85,7 @@ object BisProvider {
case "chest" => Some("body")
case "ringLeft" | "fingerL" => Some("left ring")
case "ringRight" | "fingerR" => Some("right ring")
case "weapon" | "head" | "body" | "hands" | "waist" | "legs" | "feet" | "ears" | "neck" | "wrist" | "wrists" => Some(key)
case "weapon" | "head" | "body" | "hands" | "legs" | "feet" | "ears" | "neck" | "wrist" | "wrists" => Some(key)
case _ => None
}
}

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).
@ -29,19 +29,23 @@ trait RequestExecutor {
system.classicSystem.dispatchers.lookup("me.arcanis.ffxivbis.default-dispatcher")
def sendRequest[T](uri: Uri, parser: JsObject => Future[T]): Future[T] =
http.singleRequest(HttpRequest(uri = uri)).map {
case r: HttpResponse if r.status.isRedirection() =>
val location = r.header[Location].get.uri
sendRequest(uri.withPath(location.path), parser)
case HttpResponse(status, _, entity, _) if status.isSuccess() =>
entity.dataBytes
.fold(ByteString.empty)(_ ++ _)
.map(_.utf8String)
.map(result => parser(result.parseJson.asJsObject))
.toMat(Sink.head)(Keep.right)
.run().flatten
case other => Future.failed(new Error(s"Invalid response from server $other"))
}.flatten
http
.singleRequest(HttpRequest(uri = uri))
.map {
case r: HttpResponse if r.status.isRedirection() =>
val location = r.header[Location].get.uri
sendRequest(uri.withPath(location.path), parser)
case HttpResponse(status, _, entity, _) if status.isSuccess() =>
entity.dataBytes
.fold(ByteString.empty)(_ ++ _)
.map(_.utf8String)
.map(result => parser(result.parseJson.asJsObject))
.toMat(Sink.head)(Keep.right)
.run()
.flatten
case other => Future.failed(new Error(s"Invalid response from server $other"))
}
.flatten
def shutdown(): Unit = http.shutdownAllConnectionPools()
}

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,6 +13,7 @@ import me.arcanis.ffxivbis.models.PieceType
import spray.json._
import scala.concurrent.{ExecutionContext, Future}
import scala.jdk.CollectionConverters._
import scala.util.Try
trait XivApi extends RequestExecutor {
@ -21,25 +22,52 @@ trait XivApi extends RequestExecutor {
private val xivapiUrl = config.getString("me.arcanis.ffxivbis.bis-provider.xivapi-url")
private val xivapiKey = Try(config.getString("me.arcanis.ffxivbis.bis-provider.xivapi-key")).toOption
private val preloadedItems: Map[Long, PieceType.PieceType] =
config
.getConfigList("me.arcanis.ffxivbis.bis-provider.cached-items")
.asScala
.map { item =>
item.getLong("id") -> PieceType.withName(item.getString("source"))
}
.toMap
def getPieceType(itemIds: Seq[Long]): Future[Map[Long, PieceType.PieceType]] = {
val (local, remote) = itemIds.foldLeft((Map.empty[Long, PieceType.PieceType], Seq.empty[Long])) {
case ((l, r), id) =>
if (preloadedItems.contains(id)) (l.updated(id, preloadedItems(id)), r)
else (l, r :+ id)
}
if (remote.isEmpty) Future.successful(local)
else remotePieceType(remote).map(_ ++ local)
}
private def remotePieceType(itemIds: Seq[Long]): Future[Map[Long, PieceType.PieceType]] = {
val uriForItems = Uri(xivapiUrl)
.withPath(Uri.Path / "item")
.withQuery(Uri.Query(Map(
"columns" -> Seq("ID", "GameContentLinks").mkString(","),
"ids" -> itemIds.mkString(","),
"private_key" -> xivapiKey.getOrElse("")
)))
.withQuery(
Uri.Query(
Map(
"columns" -> Seq("ID", "GameContentLinks").mkString(","),
"ids" -> itemIds.mkString(","),
"private_key" -> xivapiKey.getOrElse("")
)
)
)
sendRequest(uriForItems, XivApi.parseXivapiJsonToShop).flatMap { shops =>
val shopIds = shops.values.map(_._2).toSet
val columns = shops.values.map(pair => s"ItemCost${pair._1}").toSet
val uriForShops = Uri(xivapiUrl)
.withPath(Uri.Path / "specialshop")
.withQuery(Uri.Query(Map(
"columns" -> (columns + "ID").mkString(","),
"ids" -> shopIds.mkString(","),
"private_key" -> xivapiKey.getOrElse("")
)))
.withQuery(
Uri.Query(
Map(
"columns" -> (columns + "ID").mkString(","),
"ids" -> shopIds.mkString(","),
"private_key" -> xivapiKey.getOrElse("")
)
)
)
sendRequest(uriForShops, XivApi.parseXivapiJsonToType(shops))
}
@ -48,14 +76,15 @@ trait XivApi extends RequestExecutor {
object XivApi {
private def parseXivapiJsonToShop(js: JsObject)
(implicit executionContext: ExecutionContext): Future[Map[Long, (String, Long)]] = {
def extractTraderId(js: JsObject) = {
private def parseXivapiJsonToShop(
js: JsObject
)(implicit executionContext: ExecutionContext): Future[Map[Long, (String, Long)]] = {
def extractTraderId(js: JsObject) =
js.fields
.get("Recipe").map(_ => "crafted" -> -1L) // you can craft this item
.orElse { // lets try shop items
js.fields("SpecialShop").asJsObject
.fields.collectFirst {
.get("Recipe")
.map(_ => "crafted" -> -1L) // you can craft this item
.orElse { // lets try shop items
js.fields("SpecialShop").asJsObject.fields.collectFirst {
case (shopName, JsArray(array)) if shopName.startsWith("ItemReceive") =>
val shopId = array.head match {
case JsNumber(id) => id.toLong
@ -63,29 +92,32 @@ object XivApi {
}
shopName.replace("ItemReceive", "") -> shopId
}
}.getOrElse(throw deserializationError(s"Could not parse $js"))
}
}
.getOrElse(throw deserializationError(s"Could not parse $js"))
Future {
js.fields("Results") match {
case array: JsArray =>
array.elements.map(_.asJsObject.getFields("ID", "GameContentLinks") match {
case Seq(JsNumber(id), shop) => id.toLong -> extractTraderId(shop.asJsObject)
case other => throw deserializationError(s"Could not parse $other")
}).toMap
array.elements
.map(_.asJsObject.getFields("ID", "GameContentLinks") match {
case Seq(JsNumber(id), shop: JsObject) => id.toLong -> extractTraderId(shop.asJsObject)
case other => throw deserializationError(s"Could not parse $other")
})
.toMap
case other => throw deserializationError(s"Could not parse $other")
}
}
}
private def parseXivapiJsonToType(shops: Map[Long, (String, Long)])(js: JsObject)
(implicit executionContext: ExecutionContext): Future[Map[Long, PieceType.PieceType]] =
private def parseXivapiJsonToType(
shops: Map[Long, (String, Long)]
)(js: JsObject)(implicit executionContext: ExecutionContext): Future[Map[Long, PieceType.PieceType]] =
Future {
val shopMap = js.fields("Results") match {
case array: JsArray =>
array.elements.map { shop =>
shop.asJsObject.fields("ID") match {
case JsNumber(id) => id.toLong -> shop.asJsObject
array.elements.collect { case shop: JsObject =>
shop.fields("ID") match {
case JsNumber(id) => id.toLong -> shop
case other => throw deserializationError(s"Could not parse $other")
}
}.toMap
@ -94,18 +126,16 @@ object XivApi {
shops.map { case (itemId, (index, shopId)) =>
val pieceType =
if (index == "crafted" && shopId == -1) PieceType.Crafted
else {
if (index == "crafted" && shopId == -1L) PieceType.Crafted
else
Try(shopMap(shopId).fields(s"ItemCost$index").asJsObject)
.toOption
.getOrElse(throw new Exception(s"${shopMap(shopId).fields(s"ItemCost$index")}, $index"))
.getFields("IsUnique", "StackSize") match {
case Seq(JsNumber(isUnique), JsNumber(stackSize)) =>
if (isUnique == 1 || stackSize.toLong == 2000) PieceType.Tome // either upgraded gear or tomes found
if (isUnique == 1 || stackSize.toLong != 999) PieceType.Tome // either upgraded gear or tomes found
else PieceType.Savage
case other => throw deserializationError(s"Could not parse $other")
}
}
itemId -> pieceType
}
}

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