51 Commits

Author SHA1 Message Date
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
df1f28c7ef Release 0.9.9 2020-12-04 11:47:36 +03:00
8d516cdb15 use typed actors (#12)
* initial typed actor impl
* fix sending response on ask
2020-12-04 11:45:23 +03:00
2e16a8c1fa multi item support 2020-12-03 03:35:06 +03:00
25b05aa289 identify url in runtime 2020-11-26 23:43:14 +03:00
534ed98459 etro support 2020-11-26 23:27:33 +03:00
0171b229a1 demo with optional loot support 2020-03-17 02:13:21 +03:00
10c107d2c2 crafted items support (#5) 2020-03-13 03:36:25 +03:00
16ce0bf61c add support of party alias 2020-03-09 01:48:24 +03:00
1e6064e081 Feature/timestamp support (#8)
* initial timestamp support

* compilation & test fixes

* do not take default argument
2020-03-08 03:48:25 +03:00
92e2c1d383 less conversions 2020-03-08 02:39:54 +03:00
5eae1d46a2 Release 0.9.8 2020-02-26 01:17:33 +03:00
eb24019965 another iteration with parsing xivapi 2020-02-26 01:16:57 +03:00
173ea9079f release 0.9.7 2020-02-25 03:18:51 +03:00
12c99bd52c fix to recent xivapi changes 2020-02-25 03:17:03 +03:00
bdfb5aedeb Update README.md 2020-02-12 01:56:16 +03:00
666a1b8b7a fix api urls in swagger 2019-12-29 03:47:43 +03:00
65a4a25b3a force type number 2019-11-22 01:40:52 +03:00
37c444a5b9 badges 2019-11-18 23:38:31 +03:00
f5a644747d change travis config 2019-11-18 23:24:50 +03:00
ab790e87ff better job handling 2019-11-18 23:22:23 +03:00
9faceb4f61 some fixes 2019-11-18 23:00:54 +03:00
65b9e53b66 additional methods to types endpoint 2019-11-17 16:23:43 +03:00
ad144534a9 0.9.4
* types api
* dist instead of assembly (assembly does not allow to use swagger easily)
* small swagger improvements
2019-11-15 01:27:15 +03:00
4700768aed report errors 2019-11-11 10:03:07 +03:00
557038c262 rename property 2019-11-05 00:28:21 +03:00
6e8b64feef some improvements in loot system 2019-11-04 15:19:18 +03:00
0a71a98482 postgres config update 2019-11-03 15:48:05 +03:00
69d35c95d9 fix migration 2019-11-03 14:14:48 +03:00
155790465e add notes about downloading jar 2019-11-02 15:29:48 +03:00
da00a60332 fix assembly build 2019-11-02 15:26:29 +03:00
0bf1edfff8 add missing license headers 2019-11-02 14:52:01 +03:00
50acecd97e party cache impl, fix reject handling, correct party creation api 2019-11-02 14:41:36 +03:00
e03f8987b0 simple http log 2019-11-01 01:23:24 +03:00
2ad3600da5 rejection handling 2019-10-31 22:23:03 +03:00
a84b947862 improve api 2019-10-29 01:59:35 +03:00
f84b9cbaba moar tests 2019-10-27 23:58:43 +03:00
9f12647fed fix travis build 2019-10-27 05:48:51 +03:00
2a1eb9430e even more tests 2019-10-27 04:23:06 +03:00
4cdcd80d51 more tests 2019-10-26 19:44:51 +03:00
b228595a1b some tests 2019-10-24 08:20:03 +03:00
d1001ffb8e license headers 2019-10-17 22:58:26 +03:00
09c7efec62 full view impl 2019-10-17 21:03:30 +03:00
9668a0edd1 base views 2019-10-17 20:08:28 +03:00
d5233361e5 add some views 2019-10-17 02:01:04 +03:00
eea2f1b04b add bisview 2019-10-17 01:21:55 +03:00
49fd33fffc Scala (#3)
* initial migration to scala
2019-10-16 03:06:58 +03:00
2d84459c4d replace requests by aiohttp 2019-09-15 03:27:29 +03:00
28dabcb44e rename service to ffxivbis, add notes about venv 2019-09-15 02:57:53 +03:00
36f0b8151a fix args rename 2019-09-15 01:37:30 +03:00
222 changed files with 6804 additions and 4984 deletions

162
.gitignore vendored
View File

@ -1,96 +1,88 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
#### joe made this: http://goel.io/joe
# C extensions
*.so
#### jetbrains ####
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
parts/
sdist/
*.egg-info/
.installed.cfg
*.egg
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# User-specific stuff:
.idea
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
## File-based project format:
*.iws
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
## Plugin-specific files:
# IntelliJ
/out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
#### gradle ####
.gradle
/build/
# Ignore Gradle GUI config
gradle-app.setting
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
!gradle-wrapper.jar
# Cache of project
.gradletasknamecache
# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898
# gradle/wrapper/gradle-wrapper.properties
#### java ####
*.class
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.ear
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
#### scala ####
*.class
*.log
# sbt specific
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
*.log*
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.history
.lib/
dist/*
target/
lib_managed/
src_managed/
project/boot/
project/plugins/project/
# IPython Notebook
.ipynb_checkpoints
# Scala-IDE specific
.scala_dependencies
.worksheet
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# dotenv
.env
# virtualenv
venv/
ENV/
# Spyder project settings
.spyderproject
# Rope project settings
.ropeproject
*.deb
.idea/
.mypy_cache/
/cache
# ENSIME specific
.ensime_cache/
.ensime
*.db
*.sc

9
.travis.yml Normal file
View File

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

View File

@ -1,82 +1,31 @@
# 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)
Service which allows to manage savage loot distribution easy.
## Installation and usage
This service requires python >= 3.7. For other dependencies see `setup.py`.
In general installation process looks like:
In general compilation process looks like:
```bash
python setup.py build
python setup.py test # if you want to run tests
sbt dist
```
Service can be run from `src` directory by using command:
Or alternatively you can download latest distribution zip from the releases page. Service can be run by using command:
```bash
python -m service.application.application
bin/ffxivbis
```
To see all available options type `--help`.
from the extracted archive root.
## Web service
REST API documentation is available at `http://0.0.0.0:8000/api-docs`. HTML representation is available at `http://0.0.0.0:8000`.
REST API documentation is available at `http://0.0.0.0:8000/swagger`. HTML representation is available at `http://0.0.0.0:8000`.
*Note*: host and port depend on configuration settings.
## Configuration
## Public service
* `settings` section
General project settings.
* `include`: path to include configuration directory, string, optional.
* `logging`: path to logging configuration, see `logging.ini` for reference, string, optional.
* `database`: database provide name, string, required. Allowed values: `sqlite`, `postgres`.
* `priority`: methods of `Player` class which will be called to sort players for loot priority, space separated list of strings, required.
* `ariyala` section
Settings related to ariyala parser.
* `ariyala_url`: ariyala base url, string, required.
* `request_timeout`: xivapi request timeout, float, optional, default 30.
* `xivapi_key`: xivapi developer key, string, optional.
* `xivapi_url`: xivapi base url, string, required.
* `auth` section
Authentication settings.
* `enabled`: whether authentication enabled or not, boolean, required.
* `root_username`: username of administrator, string, required.
* `root_password`: md5 hashed password of administrator, string, required.
* `postgres` section
Database settings for `postgres` provider.
* `database`: database name, string, required.
* `host`: database host, string, required.
* `password`: database password, string, required.
* `port`: database port, int, required.
* `username`: database username, string, required.
* `migrations_path`: path to database migrations, string, required.
* `sqlite` section
Database settings for `sqlite` provider.
* `database_path`: path to sqlite database, string, required.
* `migrations_path`: path to database migrations, string, required.
* `web` section
Web server related settings.
* `host`: address to bind, string, required.
* `port`: port to bind, int, required.
* `templates`: path to directory with jinja templates, string, required.
There is also public service which is available at http://ffxivbis.arcanis.me.

View File

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

16
build.sbt Normal file
View File

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

33
libraries.sbt Normal file
View File

@ -0,0 +1,33 @@
val AkkaVersion = "2.6.10"
val AkkaHttpVersion = "10.2.1"
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 += "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 += "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.mindrot" % "jbcrypt" % "0.3m"
// testing
libraryDependencies += "org.scalactic" %% "scalactic" % "3.1.4" % "test"
libraryDependencies += "org.scalatest" %% "scalatest" % "3.1.4" % "test"
libraryDependencies += "com.typesafe.akka" %% "akka-actor-testkit-typed" % AkkaVersion % "test"
libraryDependencies += "com.typesafe.akka" %% "akka-stream-testkit" % AkkaVersion % "test"
libraryDependencies += "com.typesafe.akka" %% "akka-http-testkit" % AkkaHttpVersion % "test"

View File

@ -1,38 +0,0 @@
'''
init tables
'''
from yoyo import step
__depends__ = {}
steps = [
step('''create table players (
player_id integer primary key,
created integer not null,
nick text not null,
job text not null,
bis_link text,
priority integer not null default 1
)'''),
step('''create unique index players_nick_job_idx on players(nick, job)'''),
step('''create table loot (
loot_id integer primary key,
player_id integer not null,
created integer not null,
piece text not null,
is_tome integer not null,
foreign key (player_id) references players(player_id) on delete cascade
)'''),
step('''create index loot_owner_idx on loot(player_id)'''),
step('''create table bis (
player_id integer not null,
created integer not null,
piece text not null,
is_tome integer not null,
foreign key (player_id) references players(player_id) on delete cascade
)'''),
step('''create unique index bis_piece_player_id_idx on bis(player_id, piece)''')
]

View File

@ -1,17 +0,0 @@
'''
users table
'''
from yoyo import step
__depends__ = {}
steps = [
step('''create table users (
user_id integer primary key,
username text not null,
password text not null,
permission text not null
)'''),
step('''create unique index users_username_idx on users(username)''')
]

View File

@ -1,10 +0,0 @@
[settings]
include = ffxivbis.ini.d
logging = ffxivbis.ini.d/logging.ini
database = sqlite
priority = is_required loot_count_bis loot_priority loot_count loot_count_total
[web]
host = 0.0.0.0
port = 8000
templates = templates

View File

@ -1,4 +0,0 @@
[ariyala]
ariyala_url = https://ffxiv.ariyala.com
request_timeout = 1
xivapi_url = https://xivapi.com

View File

@ -1,4 +0,0 @@
[auth]
enabled = yes
root_username = admin
root_password = $1$R3j4sym6$HtvrKOJ66f7w3.9Zc3U6h1

View File

@ -1,44 +0,0 @@
[loggers]
keys = root,application,database,http
[handlers]
keys = file_handler
[formatters]
keys = generic_format
[handler_console_handler]
class = StreamHandler
level = INFO
formatter = generic_format
args = (sys.stdout,)
[handler_file_handler]
class = logging.handlers.RotatingFileHandler
level = INFO
formatter = generic_format
args = ('ffxivbis.log', 'a', 20971520, 20)
[formatter_generic_format]
format = [%(levelname)s] [%(asctime)s] [%(threadName)s] [%(name)s] [%(funcName)s]: %(message)s
datefmt =
[logger_root]
level = INFO
handlers = file_handler
qualname = root
[logger_application]
level = INFO
handlers = file_handler
qualname = application
[logger_database]
level = INFO
handlers = file_handler
qualname = database
[logger_http]
level = INFO
handlers = file_handler
qualname = http

View File

@ -1,3 +0,0 @@
[sqlite]
database_path = /home/arcanis/Documents/github/ffxivbis/ffxivbis.db
migrations_path = /home/arcanis/Documents/github/ffxivbis/migrations

1
project/build.properties Normal file
View File

@ -0,0 +1 @@
sbt.version = 1.3.3

3
project/plugins.sbt Normal file
View File

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

View File

@ -1,5 +0,0 @@
[aliases]
test=pytest
[tool:pytest]
addopts = --verbose --pyargs .

View File

@ -1,51 +0,0 @@
from distutils.util import convert_path
from setuptools import setup, find_packages
from os import path
here = path.abspath(path.dirname(__file__))
metadata = dict()
with open(convert_path('src/service/core/version.py')) as metadata_file:
exec(metadata_file.read(), metadata)
setup(
name='ffxivbis',
version=metadata['__version__'],
zip_safe=False,
description='Helper to handle loot drop',
author='Evgeniy Alekseev',
author_email='i@arcanis.me',
license='BSD',
packages=find_packages(exclude=['contrib', 'docs', 'tests']),
install_requires=[
'aiohttp',
'aiohttp_jinja2',
'aiohttp_security',
'apispec',
'Jinja2',
'passlib',
'requests',
'yoyo_migrations'
],
setup_requires=[
'pytest-runner'
],
tests_require=[
'pytest', 'pytest-aiohttp', 'pytest-asyncio'
],
include_package_data=True,
extras_require={
'Postgresql': ['aiopg'],
'SQLite': ['aiosqlite'],
'test': ['coverage', 'pytest'],
},
)

View File

@ -0,0 +1,36 @@
create table players (
party_id text not null,
player_id bigserial unique,
created bigint not null,
nick text not null,
job text not null,
bis_link text,
priority integer not null default 1);
create unique index players_nick_job_idx on players(party_id, nick, job);
create table loot (
loot_id bigserial unique,
player_id bigint not null,
created bigint not null,
piece text not null,
is_tome integer not null,
job text not null,
foreign key (player_id) references players(player_id) on delete cascade);
create index loot_owner_idx on loot(player_id);
create table bis (
player_id bigint not null,
created bigint not null,
piece text not null,
is_tome integer not null,
job text not null,
foreign key (player_id) references players(player_id) on delete cascade);
create unique index bis_piece_player_id_idx on bis(player_id, piece);
create table users (
party_id text not null,
user_id bigserial unique,
username text not null,
password text not null,
permission text not null);
create unique index users_username_idx on users(party_id, username);

View File

@ -0,0 +1,5 @@
update loot set piece = 'left ring' where piece = 'leftRing';
update loot set piece = 'right ring' where piece = 'rightRing';
update bis set piece = 'left ring' where piece = 'leftRing';
update bis set piece = 'right ring' where piece = 'rightRing';

View File

@ -0,0 +1,5 @@
create table parties (
player_id bigserial unique,
party_name text not null,
party_alias text);
create unique index parties_party_name_idx on parties(party_name);

View File

@ -0,0 +1,17 @@
-- loot
alter table loot add column piece_type text;
update loot set piece_type = 'Tome' where is_tome = 1;
update loot set piece_type = 'Savage' where is_tome = 0;
alter table loot alter column piece_type set not null;
alter table loot drop column is_tome;
-- bis
alter table bis add column piece_type text;
update bis set piece_type = 'Tome' where is_tome = 1;
update bis set piece_type = 'Savage' where is_tome = 0;
alter table bis alter column piece_type set not null;
alter table bis drop column is_tome;

View File

@ -0,0 +1 @@
alter table loot add column is_free_loot integer not null default 0;

View File

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

View File

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

View File

@ -0,0 +1,36 @@
create table players (
party_id text not null,
player_id integer primary key autoincrement,
created integer not null,
nick text not null,
job text not null,
bis_link text,
priority integer not null default 1);
create unique index players_nick_job_idx on players(party_id, nick, job);
create table loot (
loot_id integer primary key autoincrement,
player_id integer not null,
created integer not null,
piece text not null,
is_tome integer not null,
job text not null,
foreign key (player_id) references players(player_id) on delete cascade);
create index loot_owner_idx on loot(player_id);
create table bis (
player_id integer not null,
created integer not null,
piece text not null,
is_tome integer not null,
job text not null,
foreign key (player_id) references players(player_id) on delete cascade);
create unique index bis_piece_player_id_idx on bis(player_id, piece);
create table users (
party_id text not null,
user_id integer primary key autoincrement,
username text not null,
password text not null,
permission text not null);
create unique index users_username_idx on users(party_id, username);

View File

@ -0,0 +1,5 @@
update loot set piece = 'left ring' where piece = 'leftRing';
update loot set piece = 'right ring' where piece = 'rightRing';
update bis set piece = 'left ring' where piece = 'leftRing';
update bis set piece = 'right ring' where piece = 'rightRing';

View File

@ -0,0 +1,5 @@
create table parties (
player_id integer primary key autoincrement,
party_name text not null,
party_alias text);
create unique index parties_party_name_idx on parties(party_name);

View File

@ -0,0 +1,42 @@
-- loot
alter table loot add column piece_type text;
update loot set piece_type = 'Tome' where is_tome = 1;
update loot set piece_type = 'Savage' where is_tome = 0;
create table loot_new (
loot_id integer primary key autoincrement,
player_id integer not null,
created integer not null,
piece text not null,
piece_type text not null,
job text not null,
foreign key (player_id) references players(player_id) on delete cascade);
insert into loot_new select loot_id, player_id, created, piece, piece_type, job from loot;
drop index loot_owner_idx;
drop table loot;
alter table loot_new rename to loot;
create index loot_owner_idx on loot(player_id);
-- bis
alter table bis add column piece_type text;
update bis set piece_type = 'Tome' where is_tome = 1;
update bis set piece_type = 'Savage' where is_tome = 0;
create table bis_new (
player_id integer not null,
created integer not null,
piece text not null,
piece_type text not null,
job text not null,
foreign key (player_id) references players(player_id) on delete cascade);
insert into bis_new select player_id, created, piece, piece_type, job from bis;
drop index bis_piece_player_id_idx;
drop table bis;
alter table bis_new rename to bis;
create unique index bis_piece_player_id_idx on bis(player_id, piece);

View File

@ -0,0 +1,20 @@
alter table loot add column is_free_loot integer;
update loot set is_free_loot = 0;
create table loot_new (
loot_id integer primary key autoincrement,
player_id integer not null,
created integer not null,
piece text not null,
piece_type text not null,
job text not null,
is_free_loot integer not null,
foreign key (player_id) references players(player_id) on delete cascade);
insert into loot_new select loot_id, player_id, created, piece, piece_type, job, is_free_loot from loot;
drop index loot_owner_idx;
drop table loot;
alter table loot_new rename to loot;
create index loot_owner_idx on loot(player_id);

View File

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

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,27 @@
<included>
<appender name="application-base" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder>
<pattern>[%-5level %d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%logger{50}]: %msg%n</pattern>
</encoder>
<file>application.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
<minIndex>1</minIndex>
<maxIndex>20</maxIndex>
<fileNamePattern>application.log.%i.gz</fileNamePattern>
</rollingPolicy>
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<maxFileSize>100MB</maxFileSize>
</triggeringPolicy>
</appender>
<appender name="application" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="application-base"/>
<queueSize>50000</queueSize>
<neverBlock>true</neverBlock>
</appender>
</included>

View File

@ -0,0 +1,27 @@
<included>
<appender name="http-base" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder>
<pattern>%msg%n</pattern>
</encoder>
<file>http.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
<minIndex>1</minIndex>
<maxIndex>20</maxIndex>
<fileNamePattern>http.log.%i.gz</fileNamePattern>
</rollingPolicy>
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<maxFileSize>100MB</maxFileSize>
</triggeringPolicy>
</appender>
<appender name="http" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="http-base"/>
<queueSize>50000</queueSize>
<neverBlock>true</neverBlock>
</appender>
</included>

View File

@ -0,0 +1,16 @@
<configuration>
<include resource="logback-application.xml" />
<include resource="logback-http.xml" />
<root level="debug">
<appender-ref ref="application" />
</root>
<logger name="me.arcanis.ffxivbis" level="DEBUG" />
<logger name="http" level="DEBUG">
<appender-ref ref="http" />
</logger>
<logger name="slick" level="INFO" />
</configuration>

View File

@ -0,0 +1,67 @@
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"
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
}
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"
}
default-dispatcher {
type = Dispatcher
executor = "thread-pool-executor"
thread-pool-executor {
fixed-pool-size = 16
}
throughput = 1
}
}

View File

@ -0,0 +1,277 @@
/* 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

@ -17,7 +17,7 @@ function exportTableToCsv(filename) {
var csv = [];
for (var i = 0; i < rows.length; i++) {
if (rows[i].style.display === "none")
continue
continue;
var cols = rows[i].querySelectorAll("td, th");
var row = [];

View File

@ -0,0 +1,24 @@
REST json API description to interact with FFXIVBiS service.
# Basic workflow
* Create party using `PUT /api/v1/party` endpoint. It consumes username and password of administrator (which can't be restored). As the result it returns unique id of created party.
* Create new users which have access to this party. Note that user belongs to specific party id and in scope of the specified party it must be unique.
* Add players with their best in slot sets (probably by using ariyala links).
* Add loot items if any.
* By using `PUT /api/v1/party/{partyId}/loot` API find players which are better for the specified loot.
* Add new loot item to the selected player.
# Limitations
# Authentication
For the most party utils service requires user to be authenticated. User permission can be one of `get`, `post` or `admin`.
* `admin` permission means that the user is allowed to do anything, especially this permission is required to be able to add or modify users.
* `post` permission is required to deal with the most POST API endpoints, but to be precise only endpoints which modifies party content require this permission.
* `get` permission is required to have access to party.
`admin` permission includes any other permissions, `post` allows to perform get requests.
<security-definitions />

View File

@ -0,0 +1,60 @@
/*
* 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
import akka.actor.typed.{Behavior, PostStop, Signal}
import akka.actor.typed.scaladsl.{AbstractBehavior, ActorContext, Behaviors}
import akka.http.scaladsl.Http
import akka.stream.Materializer
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.http.RootEndpoint
import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.{Database, PartyService}
import me.arcanis.ffxivbis.storage.Migration
import scala.concurrent.ExecutionContext
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
}
private def startApplication(): Unit = {
val config = context.system.settings.config
val host = config.getString("me.arcanis.ffxivbis.web.host")
val port = config.getInt("me.arcanis.ffxivbis.web.port")
implicit val executionContext: ExecutionContext = context.system.executionContext
implicit val materializer: Materializer = Materializer(context)
Migration(config)
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)
Http()(context.system).newServerAt(host, port).bindFlow(http.route)
}
}
object Application {
def apply(): Behavior[Nothing] =
Behaviors.setup[Nothing](context => new Application(context))
}

View File

@ -0,0 +1,20 @@
/*
* 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
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)
}
}

View File

@ -0,0 +1,60 @@
/*
* 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.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 scala.concurrent.{ExecutionContext, Future}
// idea comes from https://synkre.com/bcrypt-for-akka-http-password-encryption/
trait Authorization {
def storage: ActorRef[Message]
def authenticateBasicBCrypt[T](realm: String,
authenticate: (String, String) => Future[Option[T]]): Directive1[T] = {
def challenge = HttpChallenges.basic(realm)
extractCredentials.flatMap {
case Some(BasicHttpCredentials(username, password)) =>
onSuccess(authenticate(username, password)).flatMap {
case Some(client) => provide(client)
case None => reject(AuthenticationFailedRejection(CredentialsRejected, challenge))
}
case _ => reject(AuthenticationFailedRejection(CredentialsMissing, challenge))
}
}
def authenticator(scope: Permission.Value, partyId: String)(username: String, password: String)
(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Option[String]] =
storage.ask(GetUser(partyId, username, _)).map {
case Some(user) if user.verify(password) && user.verityScope(scope) => Some(username)
case _ => None
}
def authAdmin(partyId: String)(username: String, password: String)
(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Option[String]] =
authenticator(Permission.admin, partyId)(username, password)
def authGet(partyId: String)(username: String, password: String)
(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Option[String]] =
authenticator(Permission.get, partyId)(username, password)
def authPost(partyId: String)(username: String, password: String)
(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Option[String]] =
authenticator(Permission.post, partyId)(username, password)
}

View File

@ -0,0 +1,52 @@
/*
* 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,26 @@
/*
* 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.messages.{BiSProviderMessage, DownloadBiS}
import me.arcanis.ffxivbis.models.{BiS, Job}
import scala.concurrent.Future
trait BisProviderHelper {
def provider: ActorRef[BiSProviderMessage]
def downloadBiS(link: String, job: Job.Job)
(implicit timeout: Timeout, scheduler: Scheduler): Future[BiS] =
provider.ask(DownloadBiS(link, job, _))
}

View File

@ -0,0 +1,48 @@
/*
* 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

@ -0,0 +1,63 @@
/*
* 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

@ -0,0 +1,75 @@
/*
* 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 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 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 {
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")
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 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
}
}
def route: Route =
withHttpLog {
apiRoute ~ htmlRoute ~ swagger.routes ~ swaggerUIRoute
}
private def apiRoute: Route =
ignoreTrailingSlash {
pathPrefix("api") {
pathPrefix(Segment) {
case "v1" => rootApiV1Endpoint.route
case _ => reject
}
}
}
private def htmlRoute: Route =
ignoreTrailingSlash {
pathPrefix("static") {
getFromResourceDirectory("static")
} ~ rootView.route
}
private def swaggerUIRoute: Route =
path("swagger") {
getFromResource("swagger/index.html")
} ~ getFromResourceDirectory("swagger")
}

View File

@ -0,0 +1,46 @@
/*
* 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 com.github.swagger.akka.SwaggerHttpService
import com.github.swagger.akka.model.{Info, License}
import com.typesafe.config.Config
import io.swagger.v3.oas.models.security.SecurityScheme
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]
)
override val info: Info = Info(
description = Source.fromResource("swagger-info/description.md").mkString,
version = getClass.getPackage.getImplementationVersion,
title = "FFXIV static loot tracker",
license = Some(License("BSD", "https://raw.githubusercontent.com/arcan1s/ffxivbis/master/LICENSE"))
)
override val host: String =
if (config.hasPath("me.arcanis.ffxivbis.web.hostname")) config.getString("me.arcanis.ffxivbis.web.hostname")
else s"${config.getString("me.arcanis.ffxivbis.web.host")}:${config.getString("me.arcanis.ffxivbis.web.port")}"
private val basicAuth = new SecurityScheme()
.description("basic http auth")
.`type`(SecurityScheme.Type.HTTP)
.in(SecurityScheme.In.HEADER)
.scheme("bearer")
override val securitySchemes: Map[String, SecurityScheme] = Map("basic auth" -> basicAuth)
override val unwantedDefinitions: Seq[String] =
Seq("Function1", "Function1RequestContextFutureRouteResult")
}

View File

@ -0,0 +1,41 @@
/*
* 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

@ -0,0 +1,160 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1
import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.{HttpEntity, StatusCodes}
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema}
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 me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.http.{Authorization, BiSHelper}
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 {
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",
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])))),
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])))),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("best in slot"),
)
def createBiS: Route =
path("party" / Segment / "bis") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
put {
entity(as[PlayerBiSLinkResponse]) { bisLink =>
val playerId = bisLink.playerId.withPartyId(partyId)
onComplete(putBiS(playerId, bisLink.link)) {
case Success(_) => complete(StatusCodes.Created, HttpEntity.Empty)
case Failure(exception) => throw exception
}
}
}
}
}
}
@GET
@Path("party/{partyId}/bis")
@Produces(value = Array("application/json"))
@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 = "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])))),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("best in slot"),
)
def getBiS: Route =
path("party" / Segment / "bis") { 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)
onComplete(bis(partyId, playerId)) {
case Success(response) => complete(response.map(PlayerResponse.fromPlayer))
case Failure(exception) => throw exception
}
}
}
}
}
}
@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",
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])))),
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])))),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("best in slot"),
)
def modifyBiS: Route =
path("party" / Segment / "bis") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
entity(as[PieceActionResponse]) { 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
}
}
}
}
}
}
}

View File

@ -0,0 +1,37 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1
import akka.http.scaladsl.model._
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.http.api.v1.json._
import spray.json._
trait HttpHandler extends StrictLogging { this: JsonSupport =>
implicit def exceptionHandler: ExceptionHandler = ExceptionHandler {
case ex: IllegalArgumentException =>
complete(StatusCodes.BadRequest, ErrorResponse(ex.getMessage))
case other: Exception =>
logger.error("exception during request completion", other)
complete(StatusCodes.InternalServerError, ErrorResponse("unknown server error"))
}
implicit def rejectionHandler: RejectionHandler =
RejectionHandler.default
.mapRejectionResponse {
case response @ HttpResponse(_, _, entity: HttpEntity.Strict, _) =>
val message = ErrorResponse(entity.data.utf8String).toJson
response.withEntity(HttpEntity(ContentTypes.`application/json`, message.compactPrint))
case other => other
}
}

View File

@ -0,0 +1,160 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1
import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.{HttpEntity, StatusCodes}
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema}
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 me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.http.{Authorization, LootHelper}
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 {
def route: Route = getLoot ~ modifyLoot
@GET
@Path("party/{partyId}/loot")
@Produces(value = Array("application/json"))
@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 = "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])))),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("loot"),
)
def getLoot: Route =
path("party" / Segment / "loot") { 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)
onComplete(loot(partyId, playerId)) {
case Success(response) => complete(response.map(PlayerResponse.fromPlayer))
case Failure(exception) => throw exception
}
}
}
}
}
}
@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",
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])))),
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])))),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("loot"),
)
def modifyLoot: Route =
path("party" / Segment / "loot") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
entity(as[PieceActionResponse]) { 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
}
}
}
}
}
}
@PUT
@Path("party/{partyId}/loot")
@Consumes(value = Array("application/json"))
@Produces(value = Array("application/json"))
@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])))),
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])))),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("loot"),
)
def suggestLoot: Route =
path("party" / Segment / "loot") { partyId =>
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
}
}
}
}
}
}
}

View File

@ -0,0 +1,110 @@
/*
* Copyright (c) 2020 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1
import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.{HttpEntity, StatusCodes}
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.{Content, Schema}
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 me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.http.{Authorization, PlayerHelper}
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 {
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",
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])))),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("party"),
)
def getPartyDescription: Route =
path("party" / Segment / "description") { partyId =>
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
}
}
}
}
}
@POST
@Consumes(value = Array("application/json"))
@Path("party/{partyId}/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])))),
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])))),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("party"),
)
def modifyPartyDescription: Route =
path("party" / Segment / "description") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
entity(as[PartyDescriptionResponse]) { partyDescription =>
val description = partyDescription.copy(partyId = partyId)
onComplete(updateDescription(description.toDescription)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception
}
}
}
}
}
}
}

View File

@ -0,0 +1,116 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1
import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.{HttpEntity, StatusCodes}
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema}
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 me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.http.{Authorization, PlayerHelper}
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 {
def route: Route = getParty ~ modifyParty
@GET
@Path("party/{partyId}")
@Produces(value = Array("application/json"))
@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 = "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])))),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("party"),
)
def getParty: Route =
path("party" / Segment) { 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)
onComplete(getPlayers(partyId, playerId)) {
case Success(response) => complete(response.map(PlayerResponse.fromPlayer))
case Failure(exception) => throw exception
}
}
}
}
}
}
@POST
@Path("party/{partyId}")
@Consumes(value = Array("application/json"))
@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])))),
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])))),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("party"),
)
def modifyParty: Route =
path("party" / Segment) { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
entity(as[PlayerActionResponse]) { action =>
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
}
}
}
}
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1
import akka.actor.typed.{ActorRef, Scheduler}
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.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 {
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 typesEndpoint = new TypesEndpoint(config)
private val userEndpoint = new UserEndpoint(storage)
def route: Route =
handleExceptions(exceptionHandler) {
handleRejections(rejectionHandler) {
biSEndpoint.route ~ lootEndpoint.route ~ partyEndpoint.route ~
playerEndpoint.route ~ typesEndpoint.route ~ userEndpoint.route
}
}
}

View File

@ -0,0 +1,130 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import com.typesafe.config.Config
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 me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.models.{Job, Party, Permission, Piece, PieceType}
@Path("api/v1")
class TypesEndpoint(config: Config) extends JsonSupport {
def route: Route = getJobs ~ getPermissions ~ getPieces ~ getPieceTypes ~ getPriority
@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[ErrorResponse])))),
),
tags = Array("types"),
)
def getJobs: Route =
path("types" / "jobs") {
get {
complete(Job.availableWithAnyJob.map(_.toString))
}
}
@GET
@Path("types/permissions")
@Produces(value = Array("application/json"))
@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])))),
),
tags = Array("types"),
)
def getPermissions: Route =
path("types" / "permissions") {
get {
complete(Permission.values.toSeq.sorted.map(_.toString))
}
}
@GET
@Path("types/pieces")
@Produces(value = Array("application/json"))
@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])))),
),
tags = Array("types"),
)
def getPieces: Route =
path("types" / "pieces") {
get {
complete(Piece.available)
}
}
@GET
@Path("types/pieces/types")
@Produces(value = Array("application/json"))
@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])))),
),
tags = Array("types"),
)
def getPieceTypes: Route =
path("types" / "pieces" / "types") {
get {
complete(PieceType.available.map(_.toString))
}
}
@GET
@Path("types/priority")
@Produces(value = Array("application/json"))
@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])))),
),
tags = Array("types"),
)
def getPriority: Route =
path("types" / "priority") {
get {
complete(Party.getRules(config))
}
}
}

View File

@ -0,0 +1,181 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1
import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.{HttpEntity, StatusCodes}
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema}
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 me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.http.{Authorization, UserHelper}
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 {
def route: Route = createParty ~ createUser ~ deleteUser ~ getUsers
@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])))),
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])))),
),
tags = Array("party"),
)
def createParty: Route =
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
}
}
}
}
}
@POST
@Path("party/{partyId}/users")
@Consumes(value = Array("application/json"))
@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])))),
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])))),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))),
tags = Array("users"),
)
def createUser: Route =
path("party" / Segment / "users") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ =>
post {
entity(as[UserResponse]) { 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
}
}
}
}
}
}
@DELETE
@Path("party/{partyId}/users/{username}")
@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])))),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))),
tags = Array("users"),
)
def deleteUser: Route =
path("party" / Segment / "users" / Segment) { (partyId, username) =>
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
}
}
}
}
}
@GET
@Path("party/{partyId}/users")
@Produces(value = Array("application/json"))
@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])))),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))),
tags = Array("users"),
)
def getUsers: Route =
path("party" / Segment / "users") { partyId =>
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
}
}
}
}
}
}

View File

@ -0,0 +1,13 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1.json
object ApiAction extends Enumeration {
val add, remove = Value
}

View File

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

View File

@ -0,0 +1,54 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1.json
import java.time.Instant
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
import me.arcanis.ffxivbis.models.Permission
import spray.json._
trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol {
private def enumFormat[E <: Enumeration](enum: E): RootJsonFormat[E#Value] =
new RootJsonFormat[E#Value] {
override def write(obj: E#Value): JsValue = obj.toString.toJson
override def read(json: JsValue): E#Value = json match {
case JsNumber(value) => enum(value.toInt)
case JsString(name) => enum.withName(name)
case other => deserializationError(s"String or number expected, got $other")
}
}
implicit val instantFormat: RootJsonFormat[Instant] = new RootJsonFormat[Instant] {
override def write(obj: Instant): JsValue = obj.toString.toJson
override def read(json: JsValue): Instant = json match {
case JsNumber(value) => Instant.ofEpochMilli(value.toLongExact)
case JsString(value) => Instant.parse(value)
case other => deserializationError(s"String or number expected, got $other")
}
}
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)
}

View File

@ -0,0 +1,20 @@
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,
@Schema(description = "loot timestamp", required = true) timestamp: Instant,
@Schema(description = "is loot free for all", required = true) isFreeLoot: Boolean) {
def toLoot: Loot = Loot(-1, piece.toPiece, timestamp, isFreeLoot)
}
object LootResponse {
def fromLoot(loot: Loot): LootResponse =
LootResponse(PieceResponse.fromPiece(loot.piece), loot.timestamp, loot.isFreeLoot)
}

View File

@ -0,0 +1,25 @@
/*
* Copyright (c) 2020 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.PartyDescription
case class PartyDescriptionResponse(
@Schema(description = "party id", required = true) partyId: String,
@Schema(description = "party name") partyAlias: Option[String]) {
def toDescription: PartyDescription = PartyDescription(partyId, partyAlias)
}
object PartyDescriptionResponse {
def fromDescription(description: PartyDescription): PartyDescriptionResponse =
PartyDescriptionResponse(description.partyId, description.partyAlias)
}

View File

@ -0,0 +1,14 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
case class PartyIdResponse(
@Schema(description = "party id", required = true) partyId: String)

View File

@ -0,0 +1,17 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
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])

View File

@ -0,0 +1,26 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{Job, Piece, PieceType}
case class PieceResponse(
@Schema(description = "piece type", required = true) 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) {
def toPiece: Piece = Piece(piece, PieceType.withName(pieceType), Job.withName(job))
}
object PieceResponse {
def fromPiece(piece: Piece): PieceResponse =
PieceResponse(piece.pieceType.toString, piece.job.toString, piece.piece)
}

View File

@ -0,0 +1,15 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
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)

View File

@ -0,0 +1,15 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
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)

View File

@ -0,0 +1,27 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{Job, PlayerId}
case class PlayerIdResponse(
@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) {
def withPartyId(partyId: String): PlayerId =
PlayerId(partyId, Job.withName(job), nick)
}
object PlayerIdResponse {
def fromPlayerId(playerId: PlayerId): PlayerIdResponse =
PlayerIdResponse(Some(playerId.partyId), playerId.job.toString, playerId.nick)
}

View File

@ -0,0 +1,38 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.PlayerIdWithCounters
case class PlayerIdWithCountersResponse(
@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 which are parts of best in slot", required = true) lootCountBiS: Int,
@Schema(description = "total count of looted pieces", required = true) lootCountTotal: Int)
object PlayerIdWithCountersResponse {
def fromPlayerId(playerIdWithCounters: PlayerIdWithCounters): PlayerIdWithCountersResponse =
PlayerIdWithCountersResponse(
playerIdWithCounters.partyId,
playerIdWithCounters.job.toString,
playerIdWithCounters.nick,
playerIdWithCounters.isRequired,
playerIdWithCounters.priority,
playerIdWithCounters.bisCountTotal,
playerIdWithCounters.lootCount,
playerIdWithCounters.lootCountBiS,
playerIdWithCounters.lootCountTotal)
}

View File

@ -0,0 +1,37 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{BiS, Job, Player}
case class PlayerResponse(
@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 = "link to best in slot", example = "https://ffxiv.ariyala.com/19V5R") link: Option[String],
@Schema(description = "player loot priority", `type` = "number") priority: Option[Int]) {
def toPlayer: Player =
Player(-1, partyId, Job.withName(job), nick,
BiS(bis.getOrElse(Seq.empty).map(_.toPiece)),
loot.getOrElse(Seq.empty).map(_.toLoot),
link, priority.getOrElse(0))
}
object PlayerResponse {
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))
}

View File

@ -0,0 +1,28 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{Permission, User}
case class UserResponse(
@Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: String,
@Schema(description = "username to login to party", required = true, example = "siuan") username: String,
@Schema(description = "password to login to party", required = true, example = "pa55w0rd") password: String,
@Schema(description = "user permission", defaultValue = "get", allowableValues = Array("get", "post", "admin")) permission: Option[Permission.Value] = None) {
def toUser: User =
User(partyId, username, password, permission.getOrElse(Permission.get))
}
object UserResponse {
def fromUser(user: User): UserResponse =
UserResponse(user.partyId, user.username, "", Some(user.permission))
}

View File

@ -0,0 +1,71 @@
/*
* 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

@ -0,0 +1,155 @@
/*
* 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

@ -0,0 +1,20 @@
/*
* 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

@ -0,0 +1,21 @@
/*
* 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

@ -0,0 +1,94 @@
/*
* 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

@ -0,0 +1,140 @@
/*
* 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

@ -0,0 +1,147 @@
/*
* 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

@ -0,0 +1,137 @@
/*
* 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

@ -0,0 +1,40 @@
/*
* 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.{ContentTypes, HttpEntity}
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.util.Timeout
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
class RootView(storage: ActorRef[Message],
provider: ActorRef[BiSProviderMessage])
(implicit timeout: Timeout, scheduler: Scheduler) {
private val basePartyView = new BasePartyView(storage, provider)
private val indexView = new IndexView(storage, provider)
private val biSView = new BiSView(storage, provider)
private val lootView = new LootView(storage)
private val lootSuggestView = new LootSuggestView(storage)
private val playerView = new PlayerView(storage, provider)
private val userView = new UserView(storage)
def route: Route =
basePartyView.route ~ indexView.route ~
biSView.route ~ lootView.route ~ lootSuggestView.route ~ playerView.route ~ userView.route
}
object RootView {
def toHtml(template: String): HttpEntity.Strict =
HttpEntity(ContentTypes.`text/html(UTF-8)`, template)
}

View File

@ -0,0 +1,23 @@
/*
* 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

@ -0,0 +1,130 @@
/*
* 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

@ -0,0 +1,8 @@
package me.arcanis.ffxivbis.messages
import akka.actor.typed.ActorRef
import me.arcanis.ffxivbis.models.{BiS, Job}
sealed trait BiSProviderMessage
case class DownloadBiS(link: String, job: Job.Job, replyTo: ActorRef[BiS]) extends BiSProviderMessage

View File

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

View File

@ -0,0 +1,77 @@
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.service.LootSelector
sealed trait DatabaseMessage extends Message {
def partyId: String
}
object DatabaseMessage {
type Handler = PartialFunction[DatabaseMessage, Behavior[DatabaseMessage]]
}
// bis handler
case class AddPieceToBis(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
}
case class GetBiS(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]]) extends DatabaseMessage
case class RemovePieceFromBiS(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
}
case class RemovePiecesFromBiS(playerId: PlayerId, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
}
// loot handler
case class AddPieceTo(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
}
case class GetLoot(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]]) extends DatabaseMessage
case class RemovePieceFrom(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
}
case class SuggestLoot(partyId: String, piece: Piece, replyTo: ActorRef[LootSelector.LootSelectorResult]) extends DatabaseMessage
// party handler
case class AddPlayer(player: Player, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = player.partyId
}
case class GetParty(partyId: String, replyTo: ActorRef[Party]) extends DatabaseMessage
case class GetPartyDescription(partyId: String, replyTo: ActorRef[PartyDescription]) extends DatabaseMessage
case class GetPlayer(playerId: PlayerId, replyTo: ActorRef[Option[Player]]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
}
case class RemovePlayer(playerId: PlayerId, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
}
case class UpdateParty(partyDescription: PartyDescription, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = partyDescription.partyId
}
// user handler
case class AddUser(user: User, isHashedPassword: Boolean, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = user.partyId
}
case class DeleteUser(partyId: String, username: String, replyTo: ActorRef[Unit]) extends DatabaseMessage
case class Exists(partyId: String, replyTo: ActorRef[Boolean]) extends DatabaseMessage
case class GetUser(partyId: String, username: String, replyTo: ActorRef[Option[User]]) extends DatabaseMessage
case class GetUsers(partyId: String, replyTo: ActorRef[Seq[User]]) extends DatabaseMessage

View File

@ -0,0 +1,9 @@
package me.arcanis.ffxivbis.messages
import akka.actor.typed.Behavior
trait Message
object Message {
type Handler = PartialFunction[Message, Behavior[Message]]
}

View File

@ -0,0 +1,43 @@
/*
* 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.models
case class BiS(pieces: Seq[Piece]) {
def hasPiece(piece: Piece): Boolean = piece match {
case upgrade: PieceUpgrade => upgrades.contains(upgrade)
case _ => pieces.contains(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
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
}
obj match {
case left: BiS => comparePieces(left.pieces, pieces)
case _ => false
}
}
}
object BiS {
def empty: BiS = BiS(Seq.empty)
}

View File

@ -0,0 +1,112 @@
/*
* 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.models
object Job {
sealed trait RightSide
object AccessoriesDex extends RightSide
object AccessoriesInt extends RightSide
object AccessoriesMnd extends RightSide
object AccessoriesStr extends RightSide
object AccessoriesVit extends RightSide
sealed trait LeftSide
object BodyCasters extends LeftSide
object BodyDrgs extends LeftSide
object BodyHealers extends LeftSide
object BodyMnks extends LeftSide
object BodyNins extends LeftSide
object BodyTanks extends LeftSide
object BodyRanges extends LeftSide
sealed trait Job extends Equals {
def leftSide: LeftSide
def rightSide: RightSide
// conversion to string to avoid recursion
override def canEqual(that: Any): Boolean = that.isInstanceOf[Job]
override def equals(obj: Any): Boolean = {
def equality(objRepr: String): Boolean = objRepr match {
case _ if objRepr == AnyJob.toString => true
case _ if this.toString == AnyJob.toString => true
case _ => this.toString == objRepr
}
canEqual(obj) && equality(obj.toString)
}
}
case object AnyJob extends Job {
val leftSide: LeftSide = null
val rightSide: RightSide = null
}
trait Casters extends Job {
val leftSide: LeftSide = BodyCasters
val rightSide: RightSide = AccessoriesInt
}
trait Healers extends Job {
val leftSide: LeftSide = BodyHealers
val rightSide: RightSide = AccessoriesMnd
}
trait Mnks extends Job {
val leftSide: LeftSide = BodyMnks
val rightSide: RightSide = AccessoriesStr
}
trait Tanks extends Job {
val leftSide: LeftSide = BodyTanks
val rightSide: RightSide = AccessoriesVit
}
trait Ranges extends Job {
val leftSide: LeftSide = BodyRanges
val rightSide: RightSide = AccessoriesDex
}
case object PLD extends Tanks
case object WAR extends Tanks
case object DRK extends Tanks
case object GNB extends Tanks
case object WHM extends Healers
case object SCH extends Healers
case object AST extends Healers
case object MNK extends Mnks
case object DRG extends Job {
val leftSide: LeftSide = BodyDrgs
val rightSide: RightSide = AccessoriesStr
}
case object NIN extends Job {
val leftSide: LeftSide = BodyNins
val rightSide: RightSide = AccessoriesDex
}
case object SAM extends Mnks
case object BRD extends Ranges
case object MCH extends Ranges
case object DNC extends Ranges
case object BLM extends Casters
case object SMN extends Casters
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)
lazy val availableWithAnyJob: Seq[Job] = available.prepended(AnyJob)
def withName(job: String): Job.Job =
availableWithAnyJob.find(_.toString.equalsIgnoreCase(job)) match {
case Some(value) => value
case None if job.isEmpty => AnyJob
case _ => throw new IllegalArgumentException(s"Invalid or unknown job $job")
}
}

View File

@ -0,0 +1,16 @@
/*
* 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.models
import java.time.Instant
case class Loot(playerId: Long, piece: Piece, timestamp: Instant, isFreeLoot: Boolean) {
def isFreeLootToString: String = if (isFreeLoot) "yes" else "no"
}

View File

@ -0,0 +1,57 @@
/*
* 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.models
import com.typesafe.config.Config
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.service.LootSelector
import scala.jdk.CollectionConverters._
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")
copy(players = players + (player.playerId -> player))
} catch {
case exception: Exception =>
logger.error("cannot add player", exception)
this
}
def suggestLoot(piece: Piece): LootSelector.LootSelectorResult =
LootSelector(getPlayers, piece, rules)
}
object 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)))
}
Party(party, getRules(config), playersWithItems)
}
def getRules(config: Config): Seq[String] =
config.getStringList("me.arcanis.ffxivbis.settings.priority").asScala.toSeq
def randomPartyId: String = Random.alphanumeric.take(20).mkString
}

View File

@ -0,0 +1,19 @@
/*
* Copyright (c) 2020 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
case class PartyDescription(partyId: String, partyAlias: Option[String]) {
def alias: String = partyAlias.getOrElse(partyId)
}
object PartyDescription {
def empty(partyId: String): PartyDescription = PartyDescription(partyId, None)
}

View File

@ -0,0 +1,131 @@
/*
* 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.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)
}
// used for ring comparison
def strictEqual(obj: Any): Boolean = equals(obj)
}
trait PieceAccessory extends Piece
trait PieceBody extends Piece
trait PieceUpgrade extends Piece {
val pieceType: PieceType.PieceType = PieceType.Tome
val job: Job.Job = Job.AnyJob
def withJob(other: Job.Job): Piece = this
}
trait PieceWeapon extends Piece
case class Weapon(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceWeapon {
val piece: String = "weapon"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Head(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody {
val piece: String = "head"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Body(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody {
val piece: String = "body"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Hands(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody {
val piece: String = "hands"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class 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)
}
case class Feet(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody {
val piece: String = "feet"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Ears(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceAccessory {
val piece: String = "ears"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Neck(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceAccessory {
val piece: String = "neck"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Wrist(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceAccessory {
val piece: String = "wrist"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class 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 {
case Ring(thatPieceType, thatJob, _) => (thatPieceType == pieceType) && (thatJob == job)
case _ => false
}
override def strictEqual(obj: Any): Boolean = obj match {
case ring: Ring => equals(obj) && (ring.piece == this.piece)
case _ => false
}
}
case object AccessoryUpgrade extends PieceUpgrade {
val piece: String = "accessory upgrade"
}
case object BodyUpgrade extends PieceUpgrade {
val piece: String = "body upgrade"
}
case object WeaponUpgrade extends PieceUpgrade {
val piece: String = "weapon upgrade"
}
object Piece {
def apply(piece: String, pieceType: PieceType.PieceType, job: Job.Job = Job.AnyJob): Piece =
piece.toLowerCase match {
case "weapon" => Weapon(pieceType, job)
case "head" => Head(pieceType, job)
case "body" => Body(pieceType, job)
case "hands" => Hands(pieceType, job)
case "waist" => Waist(pieceType, job)
case "legs" => Legs(pieceType, job)
case "feet" => Feet(pieceType, job)
case "ears" => Ears(pieceType, job)
case "neck" => Neck(pieceType, job)
case "wrist" | "wrists" => Wrist(pieceType, job)
case ring @ ("ring" | "left ring" | "right ring") => Ring(pieceType, job, ring)
case "accessory upgrade" => AccessoryUpgrade
case "body upgrade" => BodyUpgrade
case "weapon upgrade" => WeaponUpgrade
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")
}

View File

@ -0,0 +1,19 @@
package me.arcanis.ffxivbis.models
object PieceType {
sealed trait PieceType
case object Crafted extends PieceType
case object Tome extends PieceType
case object Savage extends PieceType
lazy val available: Seq[PieceType] =
Seq(Crafted, Tome, Savage)
def withName(pieceType: String): PieceType =
available.find(_.toString.equalsIgnoreCase(pieceType)) match {
case Some(value) => value
case _ => throw new IllegalArgumentException(s"Invalid or unknown piece type $pieceType")
}
}

View File

@ -0,0 +1,54 @@
/*
* 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.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) {
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))
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 = {
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 lootCount(piece: Option[Piece]): Int = piece match {
case Some(p) => loot.count(item => !item.isFreeLoot && item.piece == p)
case None => lootCountTotal(piece)
}
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
}

View File

@ -0,0 +1,37 @@
/*
* 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.models
import scala.util.Try
import scala.util.matching.Regex
trait PlayerIdBase {
def job: Job.Job
def nick: String
override def toString: String = s"$nick ($job)"
}
case class PlayerId(partyId: String, job: Job.Job, nick: String) extends PlayerIdBase
object PlayerId {
def apply(partyId: String, maybeNick: Option[String], maybeJob: Option[String]): Option[PlayerId] =
(maybeNick, maybeJob) match {
case (Some(nick), Some(job)) => Try(PlayerId(partyId, Job.withName(job), nick)).toOption
case _ => None
}
private val prettyPlayerIdRegex: Regex = "^(.*) \\(([A-Z]{3})\\)$".r
def apply(partyId: String, player: String): Option[PlayerId] = player match {
case s"${prettyPlayerIdRegex(nick, job)}" => Try(PlayerId(partyId, Job.withName(job), nick)).toOption
case _ => None
}
}

View File

@ -0,0 +1,54 @@
/*
* 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.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 {
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(
"isRequired" -> (if (isRequired) 1 else 0), // true has more priority
"priority" -> -priority, // the less value the more priority
"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
private def withCounters(orderBy: Seq[String]): PlayerCountersComparator =
PlayerCountersComparator(orderBy.map(counters): _*)
}
object PlayerIdWithCounters {
private case class PlayerCountersComparator(values: Int*) {
def >(that: PlayerCountersComparator): Boolean = {
@scala.annotation.tailrec
def compareLists(left: List[Int], right: List[Int]): Boolean =
(left, right) match {
case (hl :: tl, hr :: tr) => if (hl == hr) compareLists(tl, tr) else hl > hr
case (_ :: _, Nil) => true
case (_, _) => false
}
compareLists(values.toList, that.values.toList)
}
}
}

View File

@ -0,0 +1,26 @@
/*
* 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.models
import org.mindrot.jbcrypt.BCrypt
object Permission extends Enumeration {
val get, post, admin = Value
}
case class User(partyId: String,
username: String,
password: String,
permission: Permission.Value) {
def hash: String = BCrypt.hashpw(password, BCrypt.gensalt)
def verify(plain: String): Boolean = BCrypt.checkpw(plain, password)
def verityScope(scope: Permission.Value): Boolean = permission >= scope
def withHashedPassword: User = copy(password = hash)
}

View File

@ -0,0 +1,47 @@
/*
* 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
import akka.actor.typed.Behavior
import akka.actor.typed.scaladsl.Behaviors
import com.typesafe.config.Config
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.messages.{DatabaseMessage}
import me.arcanis.ffxivbis.models.{Party, Player, PlayerId}
import me.arcanis.ffxivbis.service.impl.DatabaseImpl
import me.arcanis.ffxivbis.storage.DatabaseProfile
import scala.concurrent.{ExecutionContext, Future}
trait Database extends StrictLogging {
implicit def executionContext: ExecutionContext
def config: Config
def profile: DatabaseProfile
def filterParty(party: Party, maybePlayerId: Option[PlayerId]): Seq[Player] =
maybePlayerId match {
case Some(playerId) => party.player(playerId).map(Seq(_)).getOrElse(Seq.empty)
case _ => party.getPlayers
}
def getParty(partyId: String, withBiS: Boolean, withLoot: Boolean): Future[Party] =
for {
partyDescription <- profile.getPartyDescription(partyId)
players <- profile.getParty(partyId)
bis <- if (withBiS) profile.getPiecesBiS(partyId) else Future(Seq.empty)
loot <- if (withLoot) profile.getPieces(partyId) else Future(Seq.empty)
} yield Party(partyDescription, config, players, bis, loot)
}
object Database {
def apply(): Behavior[DatabaseMessage] =
Behaviors.setup[DatabaseMessage](context => new DatabaseImpl(context))
}

View File

@ -0,0 +1,29 @@
/*
* 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
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerIdWithCounters}
class LootSelector(players: Seq[Player], piece: Piece, orderBy: Seq[String]) {
val counters: Seq[PlayerIdWithCounters] = players.map(_.withCounters(Some(piece)))
def suggest: LootSelector.LootSelectorResult =
LootSelector.LootSelectorResult {
counters.sortWith { case (left, right) => left.gt(right, orderBy) }
}
}
object LootSelector {
def apply(players: Seq[Player], piece: Piece, orderBy: Seq[String]): LootSelectorResult =
new LootSelector(players, piece, orderBy).suggest
case class LootSelectorResult(result: Seq[PlayerIdWithCounters])
}

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