├── client ├── src │ ├── assets │ │ ├── .gitkeep │ │ ├── images │ │ │ ├── 22_32_1.png │ │ │ ├── 22_32_2.png │ │ │ ├── 22_32_3.png │ │ │ ├── 22_32_4.png │ │ │ └── 22_32_5.png │ │ └── fonts │ │ │ ├── evesansneue-bold.otf │ │ │ ├── evesansneue-expanded.otf │ │ │ ├── evesansneue-italic.otf │ │ │ ├── evesansneue-regular.otf │ │ │ ├── evesansneue-bolditalic.otf │ │ │ ├── evesansneue-condensed.otf │ │ │ ├── evesansneue-condensedbold.otf │ │ │ ├── evesansneue-expandedbold.otf │ │ │ ├── evesansneue-condenseditalic.otf │ │ │ ├── evesansneue-expandeditalic.otf │ │ │ ├── evesansneue-expandedbolditalic.otf │ │ │ └── evesansneue-condensedbolditalic.otf │ ├── app │ │ ├── pages │ │ │ ├── users │ │ │ │ ├── users.component.scss │ │ │ │ ├── users.component.html │ │ │ │ └── users.component.ts │ │ │ ├── assets │ │ │ │ ├── assets.component.scss │ │ │ │ ├── assets.component.html │ │ │ │ └── assets.component.ts │ │ │ ├── ore-contents │ │ │ │ ├── ore-contents.component.scss │ │ │ │ └── ore-contents.component.html │ │ │ ├── prices-chart │ │ │ │ ├── prices-chart.component.scss │ │ │ │ ├── ore-trig.component.ts │ │ │ │ ├── gas-fullerenes.component.ts │ │ │ │ ├── gas-bgc.component.ts │ │ │ │ ├── ice.component.ts │ │ │ │ ├── ore-belt.component.ts │ │ │ │ ├── ore-moon.component.ts │ │ │ │ └── prices-chart.component.html │ │ │ ├── refining-profit │ │ │ │ ├── refining-profit.component.scss │ │ │ │ ├── refining-profit-trig.component.ts │ │ │ │ ├── refining-profit-belt.component.ts │ │ │ │ ├── refining-profit-moon.component.ts │ │ │ │ └── refining-profit.component.html │ │ │ ├── about │ │ │ │ ├── about.component.scss │ │ │ │ ├── about.component.ts │ │ │ │ └── about.component.html │ │ │ ├── home │ │ │ │ ├── home.component.scss │ │ │ │ ├── home.component.ts │ │ │ │ └── home.component.html │ │ │ ├── industry │ │ │ │ ├── industry.component.scss │ │ │ │ ├── jobs │ │ │ │ │ ├── industry-jobs.component.scss │ │ │ │ │ └── industry-jobs.component.html │ │ │ │ ├── industry.component.html │ │ │ │ └── system-overview │ │ │ │ │ └── industry-system-overview.component.scss │ │ │ ├── wallet │ │ │ │ ├── wallet.component.scss │ │ │ │ ├── wallet.component.html │ │ │ │ └── wallet.component.ts │ │ │ ├── reprocessing │ │ │ │ └── reprocessing.component.scss │ │ │ ├── production-calculator │ │ │ │ └── production-calculator.component.scss │ │ │ ├── scopes │ │ │ │ ├── scopes.component.scss │ │ │ │ └── scopes.component.html │ │ │ ├── skills │ │ │ │ └── skills.component.scss │ │ │ ├── dashboard │ │ │ │ └── dashboard.component.scss │ │ │ └── data-page │ │ │ │ └── data-page.component.ts │ │ ├── modals │ │ │ └── logout │ │ │ │ ├── logout-modal.component.scss │ │ │ │ ├── logout-modal.component.html │ │ │ │ └── logout-modal.component.ts │ │ ├── components │ │ │ ├── api-offline-message │ │ │ │ ├── api-offline-message.component.scss │ │ │ │ ├── api-offline-message.component.ts │ │ │ │ └── api-offline-message.component.html │ │ │ ├── sor-table │ │ │ │ ├── sor-table.component.scss │ │ │ │ ├── sor-table.component.html │ │ │ │ └── sor-table.component.ts │ │ │ ├── no-scopes-message │ │ │ │ ├── no-scopes-message.component.scss │ │ │ │ ├── no-scopes-message.component.ts │ │ │ │ └── no-scopes-message.component.html │ │ │ └── loading-message │ │ │ │ ├── loading-message.component.scss │ │ │ │ ├── loading-message.component.html │ │ │ │ └── loading-message.component.ts │ │ ├── shared │ │ │ ├── title.ts │ │ │ └── esi-request-cache.ts │ │ ├── models │ │ │ ├── industry │ │ │ │ ├── acquire-method.ts │ │ │ │ ├── industry-graph-layout.ts │ │ │ │ ├── industry-node.ts │ │ │ │ ├── shopping-list.ts │ │ │ │ └── industry-graph.ts │ │ │ ├── user │ │ │ │ └── user.model.ts │ │ │ └── character │ │ │ │ ├── character.service.ts │ │ │ │ └── character.model.ts │ │ ├── app.component.scss │ │ ├── app.component.html │ │ ├── guards │ │ │ ├── auth.guard.ts │ │ │ ├── admin.guard.ts │ │ │ ├── app-ready.guard.ts │ │ │ └── base.guard.ts │ │ ├── socket │ │ │ └── socket.service.ts │ │ ├── data-services │ │ │ ├── status.service.ts │ │ │ ├── systems.service.ts │ │ │ ├── stations.service.ts │ │ │ ├── constellations.service.ts │ │ │ ├── base.service.ts │ │ │ ├── wallet.service.ts │ │ │ ├── skillqueue.service.ts │ │ │ ├── industry-jobs.service.ts │ │ │ ├── attributes.service.ts │ │ │ ├── wallet-journal.service.ts │ │ │ ├── users.service.ts │ │ │ ├── ship.service.ts │ │ │ ├── search.service.ts │ │ │ ├── skills.service.ts │ │ │ ├── structures.service.ts │ │ │ ├── types.service.ts │ │ │ ├── skill-groups.service.ts │ │ │ ├── blueprints.service.ts │ │ │ ├── names.service.ts │ │ │ ├── assets.service.ts │ │ │ └── industry.service.ts │ │ ├── http-interceptors │ │ │ ├── esi-language.interceptor.ts │ │ │ ├── server-token.interceptor.ts │ │ │ ├── esi-user-agent.interceptor.ts │ │ │ ├── request-count.interceptor.ts │ │ │ ├── index.ts │ │ │ ├── esi-warning.interceptor.ts │ │ │ ├── esi-retry.interceptor.ts │ │ │ └── esi-caching.interceptor.ts │ │ ├── sentry.error-handler.ts │ │ ├── app.component.ts │ │ ├── app-ready-event.service.ts │ │ └── navigation │ │ │ └── navigation.component.scss │ ├── robots.txt │ ├── favicon.ico │ ├── _imports.scss │ ├── environments │ │ ├── environment.prod.ts │ │ ├── environment.dev.ts │ │ ├── environment.ts │ │ └── environment.local.ts │ ├── tslint.json │ ├── _addons.scss │ ├── tsconfig.app.json │ ├── sitemap.xml │ ├── main.ts │ ├── shared │ │ └── calc.helper.ts │ ├── _variables.scss │ └── index.html ├── .npmrc ├── .browserslistrc ├── proxy.conf.ts ├── nginx-proxy.conf ├── tsconfig.json ├── .gitignore ├── nginx.conf ├── tsconfig.base.json ├── Dockerfile ├── tslint.json └── package.json ├── server ├── data │ └── README.md ├── src │ ├── modules.d.ts │ ├── typings.d.ts │ ├── models │ │ ├── base.model.ts │ │ ├── blueprint.model.ts │ │ ├── character.model.ts │ │ └── user.model.ts │ ├── routers │ │ ├── global.router.ts │ │ ├── error.router.ts │ │ ├── user.router.ts │ │ └── api.router.ts │ ├── loggers │ │ ├── query.logger.ts │ │ └── request.logger.ts │ ├── controllers │ │ ├── socket.controller.ts │ │ └── database.controller.ts │ └── index.ts ├── .eslintrc.json ├── migrations │ ├── 1574949747885-RemoveEmail.ts │ ├── 1577713483793-BlueprintModel.ts │ └── 1573054086025-Initial.ts ├── Dockerfile ├── tsconfig.json ├── ormconfig.js └── package.json ├── .editorconfig ├── code-style-settings.xml ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── cd.yaml ├── LICENSE ├── docker-compose.yml └── README.md /client/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/app/pages/users/users.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/app/pages/assets/assets.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /client/src/app/modals/logout/logout-modal.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/app/pages/ore-contents/ore-contents.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/app/pages/prices-chart/prices-chart.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/data/README.md: -------------------------------------------------------------------------------- 1 | Data folder where caches are stored. 2 | -------------------------------------------------------------------------------- /client/src/app/pages/refining-profit/refining-profit.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/src/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'socket.io-express-session'; 2 | -------------------------------------------------------------------------------- /client/src/app/components/api-offline-message/api-offline-message.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/favicon.ico -------------------------------------------------------------------------------- /client/src/app/shared/title.ts: -------------------------------------------------------------------------------- 1 | export const createTitle = (title: string) => `EVIE - ${title}`; 2 | -------------------------------------------------------------------------------- /client/.npmrc: -------------------------------------------------------------------------------- 1 | @fortawesome:registry=https://npm.fontawesome.com/ 2 | //npm.fontawesome.com/:_authToken=${EVIE_FA_TOKEN} 3 | -------------------------------------------------------------------------------- /client/src/app/models/industry/acquire-method.ts: -------------------------------------------------------------------------------- 1 | export enum AcquireMethod { 2 | PURCHASE, 3 | PRODUCE, 4 | } 5 | -------------------------------------------------------------------------------- /client/src/assets/images/22_32_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/images/22_32_1.png -------------------------------------------------------------------------------- /client/src/assets/images/22_32_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/images/22_32_2.png -------------------------------------------------------------------------------- /client/src/assets/images/22_32_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/images/22_32_3.png -------------------------------------------------------------------------------- /client/src/assets/images/22_32_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/images/22_32_4.png -------------------------------------------------------------------------------- /client/src/assets/images/22_32_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/images/22_32_5.png -------------------------------------------------------------------------------- /client/src/app/pages/users/users.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/assets/fonts/evesansneue-bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/fonts/evesansneue-bold.otf -------------------------------------------------------------------------------- /client/src/app/pages/about/about.component.scss: -------------------------------------------------------------------------------- 1 | .tech-grid { 2 | display: grid; 3 | grid-template-columns: repeat(2, auto); 4 | } 5 | -------------------------------------------------------------------------------- /client/src/assets/fonts/evesansneue-expanded.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/fonts/evesansneue-expanded.otf -------------------------------------------------------------------------------- /client/src/assets/fonts/evesansneue-italic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/fonts/evesansneue-italic.otf -------------------------------------------------------------------------------- /client/src/assets/fonts/evesansneue-regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/fonts/evesansneue-regular.otf -------------------------------------------------------------------------------- /client/src/assets/fonts/evesansneue-bolditalic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/fonts/evesansneue-bolditalic.otf -------------------------------------------------------------------------------- /client/src/assets/fonts/evesansneue-condensed.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/fonts/evesansneue-condensed.otf -------------------------------------------------------------------------------- /client/src/assets/fonts/evesansneue-condensedbold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/fonts/evesansneue-condensedbold.otf -------------------------------------------------------------------------------- /client/src/assets/fonts/evesansneue-expandedbold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/fonts/evesansneue-expandedbold.otf -------------------------------------------------------------------------------- /client/src/app/pages/home/home.component.scss: -------------------------------------------------------------------------------- 1 | .index-container { 2 | .button-container { 3 | justify-content: space-evenly; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /client/src/assets/fonts/evesansneue-condenseditalic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/fonts/evesansneue-condenseditalic.otf -------------------------------------------------------------------------------- /client/src/assets/fonts/evesansneue-expandeditalic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/fonts/evesansneue-expandeditalic.otf -------------------------------------------------------------------------------- /client/src/assets/fonts/evesansneue-expandedbolditalic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/fonts/evesansneue-expandedbolditalic.otf -------------------------------------------------------------------------------- /client/src/assets/fonts/evesansneue-condensedbolditalic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ionaru/EVIE/HEAD/client/src/assets/fonts/evesansneue-condensedbolditalic.otf -------------------------------------------------------------------------------- /client/src/app/pages/industry/industry.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables'; 2 | 3 | .copy { 4 | filter: grayscale(0.1) sepia(0.4) hue-rotate(340deg) brightness(110%); 5 | } 6 | -------------------------------------------------------------------------------- /client/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | .version-tag { 2 | bottom: 0; 3 | opacity: 0.5; 4 | pointer-events: none; 5 | position: fixed; 6 | right: 5px; 7 | z-index: 0; 8 | } 9 | -------------------------------------------------------------------------------- /client/src/app/components/sor-table/sor-table.component.scss: -------------------------------------------------------------------------------- 1 | table thead th { 2 | user-select: none; 3 | vertical-align: middle; 4 | 5 | &.sortable, .hint-button { 6 | cursor: pointer; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/_imports.scss: -------------------------------------------------------------------------------- 1 | @import '../node_modules/bootstrap/scss/bootstrap'; 2 | @import '../node_modules/bootswatch/dist/superhero/bootswatch'; 3 | 4 | .justify-content-evenly { 5 | justify-content: space-evenly !important; 6 | } 7 | -------------------------------------------------------------------------------- /client/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ version }} 7 | -------------------------------------------------------------------------------- /client/src/app/components/no-scopes-message/no-scopes-message.component.scss: -------------------------------------------------------------------------------- 1 | .scopes-alert { 2 | display: grid; 3 | grid-template-columns: 38px auto; 4 | 5 | .scopes-alert-content { 6 | grid-column: 1/3; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/app/components/loading-message/loading-message.component.scss: -------------------------------------------------------------------------------- 1 | .loading-container { 2 | display: flex; 3 | align-items: center; 4 | 5 | .loading-text { 6 | line-height: 32px; 7 | padding-left: 10px; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /client/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | import { version } from '../../package.json'; 2 | 3 | export const environment = { 4 | VERSION: version, 5 | production: true, 6 | sentryEnvironment: 'production', 7 | socketHost: 'https://spaceships.app', 8 | }; 9 | -------------------------------------------------------------------------------- /client/src/environments/environment.dev.ts: -------------------------------------------------------------------------------- 1 | import { version } from '../../package.json'; 2 | 3 | export const environment = { 4 | VERSION: version, 5 | production: true, 6 | sentryEnvironment: 'development', 7 | socketHost: 'https://dev.spaceships.app', 8 | }; 9 | -------------------------------------------------------------------------------- /client/src/app/components/loading-message/loading-message.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 | -------------------------------------------------------------------------------- /client/.browserslistrc: -------------------------------------------------------------------------------- 1 | # https://github.com/browserslist/browserslist#queries 2 | 3 | last 2 Chrome versions 4 | last 2 ChromeAndroid versions 5 | last 2 Safari versions 6 | last 2 iOS versions 7 | last 2 Firefox versions 8 | last 2 FirefoxAndroid versions 9 | last 2 Edge versions 10 | -------------------------------------------------------------------------------- /client/src/app/pages/wallet/wallet.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables'; 2 | 3 | .negative { 4 | color: $danger; 5 | } 6 | 7 | app-sor-table ::ng-deep td.amount.negative { 8 | color: $danger; 9 | } 10 | 11 | app-sor-table ::ng-deep td.amount.positive { 12 | color: $success; 13 | } 14 | -------------------------------------------------------------------------------- /client/src/app/pages/assets/assets.component.html: -------------------------------------------------------------------------------- 1 |

Your blueprints:

2 | 3 |

4 | {{ getName(blueprint.type_id) }} 5 | 6 | ({{ blueprint.location_id }}) 7 | ({{ blueprint.location_name }}) 8 |

9 | -------------------------------------------------------------------------------- /client/proxy.conf.ts: -------------------------------------------------------------------------------- 1 | const PROXY_CONFIG = [ 2 | { 3 | context: [ 4 | '/api', 5 | '/sso', 6 | '/data', 7 | '/socket.io', 8 | ], 9 | secure: false, 10 | target: 'http://localhost:3731', 11 | ws: true, 12 | }, 13 | ]; 14 | 15 | module.exports = PROXY_CONFIG; 16 | -------------------------------------------------------------------------------- /client/src/app/components/api-offline-message/api-offline-message.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-api-offline-message', 5 | styleUrls: ['./api-offline-message.component.scss'], 6 | templateUrl: './api-offline-message.component.html', 7 | }) 8 | export class ApiOfflineMessageComponent { } 9 | -------------------------------------------------------------------------------- /client/src/app/guards/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { UserService } from '../models/user/user.service'; 4 | import { BaseGuard } from './base.guard'; 5 | 6 | @Injectable() 7 | export class AuthGuard extends BaseGuard { 8 | 9 | public condition() { 10 | return !!UserService.user; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_size = 4 9 | indent_style = space 10 | trim_trailing_whitespace = true 11 | 12 | [*.yaml] 13 | indent_size = 2 14 | 15 | [*.md] 16 | max_line_length = off 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /client/src/app/models/industry/industry-graph-layout.ts: -------------------------------------------------------------------------------- 1 | import { DagreLayout, DagreSettings, Orientation } from '@swimlane/ngx-graph'; 2 | 3 | export class IndustryGraphLayout extends DagreLayout { 4 | settings: DagreSettings = { 5 | orientation: Orientation.TOP_TO_BOTTOM, 6 | ranker: 'network-simplex', 7 | rankPadding: 300, 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /client/src/app/components/api-offline-message/api-offline-message.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | Could not reach EVE Online API server! Displayed data may not be current or correct. 4 |
5 | The page will refresh automatically when the API server is online again. 6 |
7 |
8 | -------------------------------------------------------------------------------- /client/src/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | true, 6 | "attribute", 7 | "app", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | true, 12 | "element", 13 | "app", 14 | "kebab-case" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/_addons.scss: -------------------------------------------------------------------------------- 1 | @import 'imports'; 2 | 3 | @keyframes shake { 4 | 10%, 90% { 5 | transform: translate3d(-1px, 0, 0); 6 | } 7 | 8 | 20%, 80% { 9 | transform: translate3d(2px, 0, 0); 10 | } 11 | 12 | 30%, 50%, 70% { 13 | transform: translate3d(-4px, 0, 0); 14 | } 15 | 16 | 40%, 60% { 17 | transform: translate3d(4px, 0, 0); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/nginx-proxy.conf: -------------------------------------------------------------------------------- 1 | proxy_redirect off; 2 | proxy_http_version 1.1; 3 | proxy_pass http://server:3731; 4 | proxy_set_header Upgrade $http_upgrade; 5 | proxy_set_header Connection 'upgrade'; 6 | proxy_set_header Host $host; 7 | proxy_set_header X-Real-IP $remote_addr; 8 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 9 | proxy_set_header X-Forwarded-Proto $scheme; 10 | proxy_cache_bypass $http_upgrade; 11 | -------------------------------------------------------------------------------- /code-style-settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 9 | 10 | -------------------------------------------------------------------------------- /client/src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "angularCompilerOptions": { 4 | // The CLI will also output a warning, disable the Angular check so the compiler doesn't crash. 5 | "disableTypeScriptVersionCheck": true 6 | }, 7 | "compilerOptions": { 8 | "outDir": "../out-tsc/app", 9 | "types": [] 10 | }, 11 | "files": [ 12 | "main.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /client/src/app/components/no-scopes-message/no-scopes-message.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { faEyeSlash } from '@fortawesome/pro-regular-svg-icons'; 3 | 4 | @Component({ 5 | selector: 'app-no-scopes-message', 6 | styleUrls: ['./no-scopes-message.component.scss'], 7 | templateUrl: './no-scopes-message.component.html', 8 | }) 9 | export class NoScopesMessageComponent { 10 | public noScopesIcon = faEyeSlash; 11 | } 12 | -------------------------------------------------------------------------------- /client/src/app/socket/socket.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { io, Socket } from 'socket.io-client'; 3 | 4 | import { environment } from '../../environments/environment'; 5 | 6 | @Injectable() 7 | export class SocketService { 8 | public static socket: Socket; 9 | 10 | constructor() { 11 | SocketService.socket = io(environment.socketHost, { 12 | reconnection: true, 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/src/app/guards/admin.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { environment } from '../../environments/environment'; 4 | import { UserService } from '../models/user/user.service'; 5 | import { BaseGuard } from './base.guard'; 6 | 7 | @Injectable() 8 | export class AdminGuard extends BaseGuard { 9 | 10 | public condition() { 11 | return !environment.production || (UserService.user && UserService.user.isAdmin); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* 2 | This is a "Solution Style" tsconfig.json file, and is used by editors and TypeScript’s language server to improve development experience. 3 | It is not intended to be used to perform a compilation. 4 | 5 | To learn more about this file see: https://angular.io/config/solution-tsconfig. 6 | */ 7 | { 8 | "files": [], 9 | "references": [ 10 | { 11 | "path": "./src/tsconfig.app.json" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /client/src/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://spaceships.app 5 | 1.0 6 | 7 | 8 | https://spaceships.app/ore 9 | 0.5 10 | 11 | 12 | https://spaceships.app/gas 13 | 0.5 14 | 15 | 16 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import '@angular/localize/init'; 3 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 4 | import 'zone.js/dist/zone'; // Included with Angular CLI. 5 | 6 | import { AppModule } from './app/app.module'; 7 | import { environment } from './environments/environment'; 8 | 9 | if (environment.production) { 10 | enableProdMode(); 11 | } 12 | 13 | platformBrowserDynamic().bootstrapModule(AppModule).then(); 14 | -------------------------------------------------------------------------------- /client/src/app/modals/logout/logout-modal.component.html: -------------------------------------------------------------------------------- 1 | 7 | 14 | -------------------------------------------------------------------------------- /client/src/app/pages/reprocessing/reprocessing.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables'; 2 | 3 | img { 4 | user-select: none; 5 | } 6 | 7 | small.form-text { 8 | display: inline-block; 9 | padding-left: 5px; 10 | } 11 | 12 | .form-group { 13 | display: grid; 14 | 15 | &.four-wide { 16 | grid-template-columns: repeat(4, 25%); 17 | } 18 | 19 | &.five-wide { 20 | grid-template-columns: repeat(5, 20%); 21 | } 22 | 23 | .form-part { 24 | padding: 0 5px; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/src/app/models/industry/industry-node.ts: -------------------------------------------------------------------------------- 1 | import { IUniverseTypeData } from '@ionaru/eve-utils'; 2 | 3 | import { AcquireMethod } from './acquire-method'; 4 | 5 | export class IndustryNode { 6 | public price = Infinity; 7 | public acquireMethod?: AcquireMethod; 8 | public totalIndustryCost = 0; 9 | public materialPrice = Infinity; 10 | public children: IndustryNode[] = []; 11 | 12 | 13 | constructor( 14 | public product: IUniverseTypeData, 15 | public quantity: number, 16 | ) { } 17 | } 18 | -------------------------------------------------------------------------------- /server/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "parserOptions": { 7 | "project": "tsconfig.json", 8 | "sourceType": "module" 9 | }, 10 | "extends": [ 11 | "@ionaru" 12 | ], 13 | "rules": { 14 | "@typescript-eslint/no-non-null-assertion": ["off"], 15 | "import/extensions": "off", 16 | "import/no-unresolved": ["error", { 17 | "ignore": [ 18 | "express-serve-static-core" 19 | ] 20 | }] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE files 2 | .idea/ 3 | .vscode/ 4 | *.iml 5 | *.code-workspace 6 | 7 | # Main files 8 | *.log 9 | .env 10 | 11 | # Client files 12 | /client/coverage/ 13 | /client/dist/ 14 | /client/node_modules/ 15 | 16 | # Client_legacy files 17 | /client_legacy/coverage/ 18 | /client_legacy/dist/ 19 | /client_legacy/node_modules/ 20 | 21 | # Server files 22 | /server/data/*.txt 23 | /server/data/*.json 24 | /server/dist/ 25 | /server/config/ 26 | /server/node_modules/ 27 | /server/.nyc_output/ 28 | 29 | # System files 30 | *.db 31 | 32 | # Node/Npm files 33 | npm-debug.log* 34 | -------------------------------------------------------------------------------- /client/src/app/models/industry/shopping-list.ts: -------------------------------------------------------------------------------- 1 | import { IndustryNode } from './industry-node'; 2 | 3 | export class ShoppingList { 4 | public readonly list: IndustryNode[] = []; 5 | 6 | public add(nodeToAdd: IndustryNode) { 7 | const existingNode = this.list.find((node) => node.product.type_id === nodeToAdd.product.type_id); 8 | if (existingNode) { 9 | existingNode.quantity += nodeToAdd.quantity; 10 | existingNode.price += nodeToAdd.price; 11 | } else { 12 | this.list.push(nodeToAdd); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/migrations/1574949747885-RemoveEmail.ts: -------------------------------------------------------------------------------- 1 | import {MigrationInterface, QueryRunner} from 'typeorm'; 2 | 3 | // noinspection JSUnusedGlobalSymbols 4 | export class RemoveEmail1574949747885 implements MigrationInterface { 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query('ALTER TABLE `user` DROP COLUMN `email`', undefined); 8 | } 9 | 10 | public async down(queryRunner: QueryRunner): Promise { 11 | await queryRunner.query('ALTER TABLE `user` ADD `email` varchar(255) NULL', undefined); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /client/src/app/pages/about/about.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Title } from '@angular/platform-browser'; 3 | 4 | import { createTitle } from '../../shared/title'; 5 | 6 | @Component({ 7 | selector: 'app-about', 8 | styleUrls: ['./about.component.scss'], 9 | templateUrl: './about.component.html', 10 | }) 11 | export class AboutComponent implements OnInit { 12 | 13 | constructor(private title: Title) { 14 | } 15 | 16 | public ngOnInit(): void { 17 | this.title.setTitle(createTitle('About')); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/src/app/components/no-scopes-message/no-scopes-message.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | EVIE does not have the required API authorization to display this page 5 |
6 |
7 | 8 |
9 | Click here to edit of refresh the API authorization. 10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine 2 | 3 | ## INSTALL SERVER 4 | 5 | RUN mkdir -p /app/data /app/config 6 | WORKDIR /app 7 | 8 | # Copy needed build files 9 | COPY ./package.json ./package-lock.json ./tsconfig.json ./ormconfig.js ./ 10 | 11 | # Install dependencies 12 | RUN npm ci 13 | 14 | # Copy source files 15 | COPY ./src ./src 16 | COPY ./migrations ./migrations 17 | 18 | # Build server for production 19 | ENV NODE_ENV production 20 | RUN npm run build 21 | RUN npm cache clean --force 22 | 23 | # Add volumes 24 | VOLUME /app/data /app/config 25 | 26 | ## RUN 27 | 28 | CMD ["npm", "start"] 29 | -------------------------------------------------------------------------------- /client/src/app/pages/ore-contents/ore-contents.component.html: -------------------------------------------------------------------------------- 1 |

Value and refining products per m³ of ore.

2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Loading... 13 | 14 | 15 | 16 |

{{ message }}

17 |
18 | -------------------------------------------------------------------------------- /client/src/app/pages/wallet/wallet.component.html: -------------------------------------------------------------------------------- 1 | 2 | To display the wallet page, you need to grant the "Read wallet balance" scope. 3 | 4 | 5 |
6 |

ISK

7 |

Tax paid: -{{ taxAmount | number: '0.2-2' }} ISK

8 | 9 |
10 | -------------------------------------------------------------------------------- /client/src/app/pages/prices-chart/ore-trig.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { PricesChartComponent } from './prices-chart.component'; 3 | import { EVE } from '@ionaru/eve-utils'; 4 | 5 | @Component({ 6 | selector: 'app-ore', 7 | styleUrls: ['./prices-chart.component.scss'], 8 | templateUrl: './prices-chart.component.html', 9 | }) 10 | export class OreTrigComponent extends PricesChartComponent implements OnInit { 11 | public async ngOnInit() { 12 | this.set = [ 13 | ...EVE.ores.abyssal, 14 | ]; 15 | super.ngOnInit().then(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/app/pages/prices-chart/gas-fullerenes.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { PricesChartComponent } from './prices-chart.component'; 3 | import { EVE } from '@ionaru/eve-utils'; 4 | 5 | @Component({ 6 | selector: 'app-ore', 7 | styleUrls: ['./prices-chart.component.scss'], 8 | templateUrl: './prices-chart.component.html', 9 | }) 10 | export class GasFullerenesComponent extends PricesChartComponent implements OnInit { 11 | public async ngOnInit() { 12 | this.set = [ 13 | ...EVE.gasses.fullerenes, 14 | ]; 15 | super.ngOnInit().then(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/app/guards/app-ready.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Resolve } from '@angular/router'; 3 | import { Observable, of } from 'rxjs'; 4 | 5 | import { AppReadyEventService } from '../app-ready-event.service'; 6 | 7 | @Injectable() 8 | export class AppReadyGuard implements Resolve { 9 | 10 | /** 11 | * Resolves if the app has started correctly. 12 | */ 13 | public resolve(): Observable { 14 | if (AppReadyEventService.appReady) { 15 | return of(true); 16 | } else { 17 | return AppReadyEventService.appReadyEvent; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /client/src/app/pages/prices-chart/gas-bgc.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { PricesChartComponent } from './prices-chart.component'; 3 | import { EVE } from '@ionaru/eve-utils'; 4 | 5 | @Component({ 6 | selector: 'app-ore', 7 | styleUrls: ['./prices-chart.component.scss'], 8 | templateUrl: './prices-chart.component.html', 9 | }) 10 | export class GasBoosterGasCloudsComponent extends PricesChartComponent implements OnInit { 11 | public async ngOnInit() { 12 | this.set = [ 13 | ...EVE.gasses.boosterGasClouds, 14 | ]; 15 | super.ngOnInit().then(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/app/models/user/user.model.ts: -------------------------------------------------------------------------------- 1 | import { Character, IApiCharacterData } from '../character/character.model'; 2 | 3 | export class User { 4 | public uuid?: string; 5 | public isAdmin: boolean; 6 | public characters: Character[] = []; 7 | 8 | constructor(data: IUserApiData) { 9 | this.uuid = data.uuid; 10 | this.isAdmin = data.isAdmin; 11 | } 12 | } 13 | 14 | export interface ISSOAuthResponseData { 15 | user: IUserApiData; 16 | newCharacter: string; 17 | } 18 | 19 | export interface IUserApiData { 20 | username?: string; 21 | uuid: string; 22 | isAdmin: boolean; 23 | characters: IApiCharacterData[]; 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | labels: Feature 5 | 6 | --- 7 | 8 | **Is your feature request related to a problem? Please describe.** 9 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 10 | 11 | **Describe the solution you'd like** 12 | A clear and concise description of what you want to happen. 13 | 14 | **Describe alternatives you've considered** 15 | A clear and concise description of any alternative solutions or features you've considered. 16 | 17 | **Additional context** 18 | Add any other context or screenshots about the feature request here. 19 | -------------------------------------------------------------------------------- /client/src/app/modals/logout/logout-modal.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { faTimes } from '@fortawesome/pro-solid-svg-icons'; 3 | import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; 4 | 5 | import { UserService } from '../../models/user/user.service'; 6 | 7 | @Component({ 8 | styleUrls: ['./logout-modal.component.scss'], 9 | templateUrl: './logout-modal.component.html', 10 | }) 11 | export class LogoutModalComponent { 12 | 13 | public faTimes = faTimes; 14 | 15 | constructor(public activeModal: NgbActiveModal, private userService: UserService) { } 16 | 17 | public logout = () => this.userService.logoutUser(); 18 | } 19 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | -------------------------------------------------------------------------------- /client/src/app/pages/refining-profit/refining-profit-trig.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { RefiningProfitComponent } from './refining-profit.component'; 4 | import { EVE } from '@ionaru/eve-utils'; 5 | 6 | @Component({ 7 | selector: 'app-refining-profit', 8 | styleUrls: ['./refining-profit.component.scss'], 9 | templateUrl: './refining-profit.component.html', 10 | }) 11 | export class RefiningProfitTrigComponent extends RefiningProfitComponent implements OnInit { 12 | public async ngOnInit() { 13 | this.set = [ 14 | ...EVE.ores.abyssal, 15 | ]; 16 | super.ngOnInit().then(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/src/app/pages/prices-chart/ice.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { PricesChartComponent } from './prices-chart.component'; 3 | import { EVE } from '@ionaru/eve-utils'; 4 | 5 | @Component({ 6 | selector: 'app-ore', 7 | styleUrls: ['./prices-chart.component.scss'], 8 | templateUrl: './prices-chart.component.html', 9 | }) 10 | export class IceComponent extends PricesChartComponent implements OnInit { 11 | public async ngOnInit() { 12 | this.set = [ 13 | ...EVE.ice.faction, 14 | ...EVE.ice.enriched, 15 | ...EVE.ice.standard, 16 | ]; 17 | super.ngOnInit().then(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/src/app/data-services/status.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE, IStatusData } from '@ionaru/eve-utils'; 4 | 5 | import { BaseService } from './base.service'; 6 | 7 | @Injectable() 8 | export class StatusService extends BaseService { 9 | 10 | public async getStatus(): Promise { 11 | const url = EVE.getStatusUrl(); 12 | const response = await this.http.get(url).toPromise().catch(this.catchHandler); 13 | if (response instanceof HttpErrorResponse) { 14 | return; 15 | } 16 | return response; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/src/app/pages/prices-chart/ore-belt.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { PricesChartComponent } from './prices-chart.component'; 3 | import { EVE } from '@ionaru/eve-utils'; 4 | 5 | @Component({ 6 | selector: 'app-ore', 7 | styleUrls: ['./prices-chart.component.scss'], 8 | templateUrl: './prices-chart.component.html', 9 | }) 10 | export class OreBeltComponent extends PricesChartComponent implements OnInit { 11 | public async ngOnInit() { 12 | this.set = [ 13 | ...EVE.ores.belt.highSec, 14 | ...EVE.ores.belt.lowSec, 15 | ...EVE.ores.belt.nullSec, 16 | ]; 17 | super.ngOnInit().then(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/src/app/components/loading-message/loading-message.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-loading-message', 5 | styleUrls: ['./loading-message.component.scss'], 6 | templateUrl: './loading-message.component.html', 7 | }) 8 | export class LoadingMessageComponent implements OnInit { 9 | 10 | @Input() 11 | public small = false; 12 | 13 | @ViewChild('spinner', { 14 | static: true, 15 | }) 16 | public spinner?: ElementRef; 17 | 18 | public ngOnInit(): void { 19 | if (this.spinner && this.small) { 20 | this.spinner.nativeElement.classList.add('spinner-border-sm'); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/app/http-interceptors/esi-language.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE } from '@ionaru/eve-utils'; 4 | 5 | /** 6 | * Interceptor to force the language to English for requests to the ESI. 7 | */ 8 | @Injectable() 9 | export class EsiLanguageInterceptor implements HttpInterceptor { 10 | 11 | public intercept(request: HttpRequest, next: HttpHandler) { 12 | 13 | if (request.url.includes(EVE.ESIURL)) { 14 | request = request.clone({ 15 | setHeaders: {'Accept-Language': 'en-us'}, 16 | }); 17 | } 18 | 19 | return next.handle(request); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /client/src/app/sentry.error-handler.ts: -------------------------------------------------------------------------------- 1 | import { ErrorHandler, Injectable } from '@angular/core'; 2 | import * as Sentry from '@sentry/browser'; 3 | 4 | import { environment } from '../environments/environment'; 5 | 6 | Sentry.init({ 7 | dsn: 'https://4064eff091454347b283cc8b939a99a0@sentry.io/1318977', 8 | enabled: environment.production, 9 | environment: environment.sentryEnvironment, 10 | release: `evie-client@${environment.VERSION}`, 11 | }); 12 | 13 | @Injectable() 14 | export class SentryErrorHandler implements ErrorHandler { 15 | public handleError(error: any) { 16 | Sentry.captureException(error.originalError || error); 17 | // tslint:disable-next-line:no-console 18 | console.error(error); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /client/src/app/data-services/systems.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE, IUniverseSystemData } from '@ionaru/eve-utils'; 4 | 5 | import { BaseService } from './base.service'; 6 | 7 | @Injectable() 8 | export class SystemsService extends BaseService { 9 | 10 | public async getSystemInfo(systemId: number): Promise { 11 | const url = EVE.getUniverseSystemUrl(systemId); 12 | const response = await this.http.get(url).toPromise().catch(this.catchHandler); 13 | if (response instanceof HttpErrorResponse) { 14 | return; 15 | } 16 | return response; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | server_name _; 3 | listen 80 default_server; 4 | error_log /var/log/nginx/evie-client-error.log; 5 | access_log /var/log/nginx/evie-client-access.log; 6 | 7 | root /app; 8 | 9 | location / { 10 | index index.html; 11 | try_files $uri $uri/index.html /index.html; 12 | } 13 | 14 | location /api { 15 | include /etc/nginx/conf.d/proxy/nginx-proxy.conf; 16 | } 17 | 18 | location /sso { 19 | include /etc/nginx/conf.d/proxy/nginx-proxy.conf; 20 | } 21 | 22 | location /data { 23 | include /etc/nginx/conf.d/proxy/nginx-proxy.conf; 24 | } 25 | 26 | location /socket.io { 27 | include /etc/nginx/conf.d/proxy/nginx-proxy.conf; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/src/app/data-services/stations.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE, IUniverseStationData } from '@ionaru/eve-utils'; 4 | 5 | import { BaseService } from './base.service'; 6 | 7 | @Injectable() 8 | export class StationsService extends BaseService { 9 | 10 | public async getStationInfo(stationId: number): Promise { 11 | const url = EVE.getUniverseStationUrl(stationId); 12 | const response = await this.http.get(url).toPromise().catch(this.catchHandler); 13 | if (response instanceof HttpErrorResponse) { 14 | return; 15 | } 16 | return response; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/src/app/pages/refining-profit/refining-profit-belt.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { RefiningProfitComponent } from './refining-profit.component'; 4 | import { EVE } from '@ionaru/eve-utils'; 5 | 6 | @Component({ 7 | selector: 'app-refining-profit', 8 | styleUrls: ['./refining-profit.component.scss'], 9 | templateUrl: './refining-profit.component.html', 10 | }) 11 | export class RefiningProfitBeltComponent extends RefiningProfitComponent implements OnInit { 12 | public async ngOnInit() { 13 | this.set = [ 14 | ...EVE.ores.belt.highSec, 15 | ...EVE.ores.belt.lowSec, 16 | ...EVE.ores.belt.nullSec, 17 | ]; 18 | super.ngOnInit().then(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /client/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "declaration": false, 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "module": "esnext", 8 | "moduleResolution": "node", 9 | "noFallthroughCasesInSwitch": true, 10 | "noImplicitReturns": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "resolveJsonModule": true, 14 | "sourceMap": true, 15 | "strict": true, 16 | "typeRoots": [ 17 | "node_modules/@types" 18 | ], 19 | "lib": [ 20 | "dom", 21 | "esnext" 22 | ], 23 | "outDir": "./dist/out-tsc", 24 | "target": "es2016" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug or defect 4 | labels: Bug 5 | 6 | --- 7 | 8 | **Short description** 9 | A clear and concise description of what the issue is. 10 | 11 | **To Reproduce** 12 | Steps to reproduce the behavior: 13 | 1. Go to '...' 14 | 2. Click on '....' 15 | 3. Scroll down to '....' 16 | 4. See error 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Screenshots** 22 | If possible, add screenshots to help explain your problem. 23 | 24 | **Environment (please complete the following information):** 25 | - OS: [e.g. MacOS] 26 | - Browser [e.g. Chrome, Safari] 27 | - Version [e.g. 68] 28 | 29 | **Additional information** 30 | Add any other info or notes about the problem here. 31 | -------------------------------------------------------------------------------- /client/src/app/http-interceptors/server-token.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | 4 | import { BaseService } from '../data-services/base.service'; 5 | 6 | /** 7 | * Interceptor to add the x-evie-token header when communicating with the backend server. 8 | */ 9 | @Injectable() 10 | export class ServerTokenInterceptor implements HttpInterceptor { 11 | 12 | public intercept(request: HttpRequest, next: HttpHandler) { 13 | 14 | if (request.url.startsWith('data/')) { 15 | request = request.clone({ 16 | setHeaders: {'x-evie-token': BaseService.serverToken}, 17 | }); 18 | } 19 | 20 | return next.handle(request); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine as build 2 | 3 | ## INSTALL CLIENT 4 | 5 | RUN mkdir /app 6 | WORKDIR /app 7 | 8 | # Copy needed build files 9 | COPY ./.browserslistrc ./.npmrc ./package.json ./package-lock.json ./angular.json ./tsconfig.base.json ./ 10 | 11 | # Install dependencies 12 | ARG EVIE_FA_TOKEN 13 | RUN npm ci 14 | 15 | # Copy source files 16 | COPY ./src ./src 17 | 18 | # Build client for production 19 | ENV NODE_ENV production 20 | ARG EVIE_ENV 21 | RUN npx ng build --configuration=${EVIE_ENV} 22 | 23 | ## RUN NGINX 24 | 25 | FROM nginx:mainline-alpine as serve 26 | 27 | COPY ./nginx.conf /etc/nginx/conf.d 28 | RUN mkdir /etc/nginx/conf.d/proxy 29 | COPY ./nginx-proxy.conf /etc/nginx/conf.d/proxy 30 | RUN rm /etc/nginx/conf.d/default.conf 31 | 32 | COPY --from=build /app/dist/client /app 33 | -------------------------------------------------------------------------------- /client/src/app/data-services/constellations.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE, IUniverseConstellationData } from '@ionaru/eve-utils'; 4 | 5 | import { BaseService } from './base.service'; 6 | 7 | @Injectable() 8 | export class ConstellationsService extends BaseService { 9 | 10 | public async getConstellation(constellationId: number): Promise { 11 | const url = EVE.getUniverseConstellationUrl(constellationId); 12 | const response = await this.http.get(url).toPromise().catch(this.catchHandler); 13 | if (response instanceof HttpErrorResponse) { 14 | return; 15 | } 16 | return response; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/src/app/pages/production-calculator/production-calculator.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables'; 2 | 3 | .input-container { 4 | label { 5 | width: 30%; 6 | 7 | input { 8 | background: transparent; 9 | border-color: $primary; 10 | color: $primary; 11 | padding: 0.375rem 0.75rem; 12 | 13 | &.is-invalid { 14 | color: $danger; 15 | animation: shake 0.82s cubic-bezier(.36,.07,.19,.97) both; 16 | } 17 | 18 | &.is-valid { 19 | color: $success; 20 | } 21 | } 22 | } 23 | } 24 | 25 | .industry-graph { 26 | border: 1px solid $body-color; 27 | 28 | #arrow { 29 | stroke: $body-color; 30 | fill: $body-color; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/src/app/pages/industry/jobs/industry-jobs.component.scss: -------------------------------------------------------------------------------- 1 | @import '../industry.component'; 2 | 3 | .industry-job-container { 4 | display: grid; 5 | grid-template-columns: 40px 64px 40px 64px auto auto; 6 | 7 | padding: 10px; 8 | box-shadow: 0 0 5px 0 rgba(0,0,0,0.75); 9 | 10 | .icon-container { 11 | align-self: center; 12 | justify-self: center; 13 | } 14 | 15 | .percentage-container { 16 | align-self: center; 17 | justify-self: right; 18 | display: grid; 19 | text-align: right; 20 | 21 | span { 22 | justify-self: right; 23 | } 24 | } 25 | } 26 | 27 | .blink { 28 | animation: blink 1.5s ease-in infinite; 29 | } 30 | 31 | @keyframes blink { 32 | from, to { opacity: 1 } 33 | 50% { opacity: 0 } 34 | } 35 | -------------------------------------------------------------------------------- /client/src/app/pages/prices-chart/ore-moon.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { PricesChartComponent } from './prices-chart.component'; 3 | import { EVE } from '@ionaru/eve-utils'; 4 | 5 | @Component({ 6 | selector: 'app-ore', 7 | styleUrls: ['./prices-chart.component.scss'], 8 | templateUrl: './prices-chart.component.html', 9 | }) 10 | export class OreMoonComponent extends PricesChartComponent implements OnInit { 11 | public async ngOnInit() { 12 | this.set = [ 13 | ...EVE.ores.moon.standard, 14 | ...EVE.ores.moon.ubiquitous, 15 | ...EVE.ores.moon.common, 16 | ...EVE.ores.moon.uncommon, 17 | ...EVE.ores.moon.rare, 18 | ...EVE.ores.moon.exceptional, 19 | ]; 20 | super.ngOnInit().then(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | interface IAuthResponseData { 4 | access_token: string; 5 | token_type: string; 6 | expires_in: number; 7 | refresh_token: string; 8 | } 9 | 10 | interface IJWTToken { 11 | scp: string[] | string; 12 | jti: string; 13 | kid: string; 14 | sub: string; 15 | azp: string; 16 | name: string; 17 | owner: string; 18 | exp: number; 19 | iss: string; 20 | } 21 | 22 | import 'express-session'; 23 | 24 | declare module 'express-session' { 25 | // eslint-disable-next-line @typescript-eslint/naming-convention 26 | interface SessionData { 27 | characterUUID?: string; 28 | token?: string; 29 | socket?: string; 30 | state?: string; 31 | user: { 32 | id?: number; 33 | }; 34 | uuid?: string; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "declaration": false, 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "moduleResolution": "node", 8 | "noFallthroughCasesInSwitch": true, 9 | "noImplicitReturns": true, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "resolveJsonModule": true, 13 | "sourceMap": true, 14 | "strict": true, 15 | "typeRoots": [ 16 | "node_modules/@types" 17 | ], 18 | "lib": [ 19 | "es2019" 20 | ], 21 | "module": "commonjs", 22 | "outDir": "./dist", 23 | "target": "es2019" 24 | }, 25 | "files": [ 26 | "src/index.ts", 27 | "src/modules.d.ts", 28 | "src/typings.d.ts" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /client/src/app/http-interceptors/esi-user-agent.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE } from '@ionaru/eve-utils'; 4 | 5 | import { environment } from '../../environments/environment'; 6 | 7 | /** 8 | * Interceptor to set a custom User Agent header when communicating with the ESI, for transparency. 9 | */ 10 | @Injectable() 11 | export class EsiUserAgentInterceptor implements HttpInterceptor { 12 | 13 | public intercept(request: HttpRequest, next: HttpHandler) { 14 | 15 | if (request.url.includes(EVE.ESIURL)) { 16 | request = request.clone({ 17 | setHeaders: {'X-User-Agent': `EVIE ${environment.VERSION}, by #Ionaru`}, 18 | }); 19 | } 20 | 21 | return next.handle(request); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | import { version } from '../../package.json'; 6 | 7 | export const environment = { 8 | VERSION: version, 9 | production: false, 10 | sentryEnvironment: 'local', 11 | socketHost: 'http://localhost:4200/', 12 | }; 13 | 14 | /* 15 | * In development mode, to ignore zone related error stack frames such as 16 | * `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can 17 | * import the following file, but please comment it out in production mode 18 | * because it will have performance impact when throwing an error. 19 | */ 20 | import 'zone.js/dist/zone-error'; // Included with Angular CLI. 21 | -------------------------------------------------------------------------------- /client/src/environments/environment.local.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | import { version } from '../../package.json'; 6 | 7 | export const environment = { 8 | VERSION: version, 9 | production: false, 10 | sentryEnvironment: 'local', 11 | socketHost: 'http://localhost:4200/', 12 | }; 13 | 14 | /* 15 | * In development mode, to ignore zone related error stack frames such as 16 | * `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can 17 | * import the following file, but please comment it out in production mode 18 | * because it will have performance impact when throwing an error. 19 | */ 20 | import 'zone.js/dist/zone-error'; // Included with Angular CLI. 21 | -------------------------------------------------------------------------------- /server/src/models/base.model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | CreateDateColumn, 5 | Entity, 6 | Generated, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 10 | 11 | @Entity() 12 | export class BaseModel extends BaseEntity { 13 | 14 | @PrimaryGeneratedColumn() 15 | public id!: number; 16 | 17 | @Column() 18 | @Generated('uuid') 19 | public uuid!: string; 20 | 21 | @CreateDateColumn({ 22 | select: false, 23 | }) 24 | public createdOn!: Date; 25 | 26 | @UpdateDateColumn({ 27 | select: false, 28 | }) 29 | public updatedOn!: Date; 30 | 31 | // noinspection JSUnusedGlobalSymbols 32 | public static async deleteAll(): Promise { 33 | await this.createQueryBuilder() 34 | .delete() 35 | .from(this) 36 | .execute(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /client/src/app/pages/industry/industry.component.html: -------------------------------------------------------------------------------- 1 | 2 | To display the industry page, you need to grant the "Read industry jobs" and "Read blueprints" scopes. 3 | 4 | 5 |
6 | 7 |
8 |
9 | 12 | 15 |
16 |
17 | 18 |
19 | 20 | 21 |
22 | -------------------------------------------------------------------------------- /client/src/app/pages/refining-profit/refining-profit-moon.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { RefiningProfitComponent } from './refining-profit.component'; 4 | import { EVE } from '@ionaru/eve-utils'; 5 | 6 | @Component({ 7 | selector: 'app-refining-profit', 8 | styleUrls: ['./refining-profit.component.scss'], 9 | templateUrl: './refining-profit.component.html', 10 | }) 11 | export class RefiningProfitMoonComponent extends RefiningProfitComponent implements OnInit { 12 | public async ngOnInit() { 13 | this.set = [ 14 | ...EVE.ores.moon.standard, 15 | ...EVE.ores.moon.ubiquitous, 16 | ...EVE.ores.moon.common, 17 | ...EVE.ores.moon.uncommon, 18 | ...EVE.ores.moon.rare, 19 | ...EVE.ores.moon.exceptional, 20 | ]; 21 | super.ngOnInit().then(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/app/pages/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Title } from '@angular/platform-browser'; 3 | 4 | import { UserService } from '../../models/user/user.service'; 5 | import { createTitle } from '../../shared/title'; 6 | 7 | @Component({ 8 | selector: 'app-home', 9 | styleUrls: ['./home.component.scss'], 10 | templateUrl: './home.component.html', 11 | }) 12 | export class HomeComponent implements OnInit { 13 | 14 | public isLoggedIn = false; 15 | 16 | constructor(private title: Title, private userService: UserService) { } 17 | 18 | public ngOnInit() { 19 | this.title.setTitle(createTitle('Home')); 20 | this.isLoggedIn = !!UserService.user; 21 | UserService.userChangeEvent.subscribe((user) => { 22 | this.isLoggedIn = !!user; 23 | }); 24 | } 25 | 26 | public authCharacter() { 27 | this.userService.ssoLogin(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /server/src/models/blueprint.model.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne } from 'typeorm'; 2 | 3 | import { BaseModel } from './base.model'; 4 | import { Character } from './character.model'; 5 | 6 | @Entity() 7 | export class Blueprint extends BaseModel { 8 | 9 | @Column() 10 | public typeId: number; 11 | 12 | @Column({ 13 | default: 0, 14 | type: Number, 15 | }) 16 | public materialEfficiency = 0; 17 | 18 | @Column({ 19 | default: 0, 20 | type: Number, 21 | }) 22 | public timeEfficiency = 0; 23 | 24 | @Column({ 25 | default: false, 26 | type: Boolean, 27 | }) 28 | public isCopy = false; 29 | 30 | @ManyToOne(() => Character, (character) => character.blueprints, { 31 | onDelete: 'CASCADE', 32 | }) 33 | public character!: Character; 34 | 35 | public constructor(id: number, typeId: number) { 36 | super(); 37 | this.id = id; 38 | this.typeId = typeId; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src/app/data-services/base.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpErrorResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | 4 | import { Character } from '../models/character/character.model'; 5 | 6 | export interface IServerResponse { 7 | state: string; 8 | message: string; 9 | data: T; 10 | } 11 | 12 | @Injectable() 13 | export class BaseService { 14 | 15 | public static readonly pagesHeaderName = 'x-pages'; 16 | public static serverToken = ''; 17 | 18 | protected static confirmRequiredScope(character: Character, scope: string, functionName: string) { 19 | if (!character.hasScope(scope)) { 20 | throw new Error(`Character ${ character.name } (${ character.uuid }) does not have\ 21 | required scope "${ scope }" for ${ functionName }().`); 22 | } 23 | } 24 | 25 | constructor(protected http: HttpClient) { } 26 | 27 | protected catchHandler = (parameter: HttpErrorResponse): HttpErrorResponse => parameter; 28 | } 29 | -------------------------------------------------------------------------------- /client/src/app/pages/scopes/scopes.component.scss: -------------------------------------------------------------------------------- 1 | .button-grid-container { 2 | display: grid; 3 | grid-template-columns: auto 40px; 4 | 5 | //@include media-breakpoint-up(md) { 6 | // width: 75%; 7 | //} 8 | 9 | &.all-scopes-button { 10 | grid-template-columns: auto; 11 | } 12 | 13 | .button-label-grid { 14 | display: grid; 15 | grid-template-columns: 24px auto auto; 16 | text-align: left; 17 | 18 | fa-icon { 19 | text-align: center; 20 | } 21 | 22 | .button-label-text { 23 | padding-left: 12px; 24 | } 25 | } 26 | 27 | .info-container { 28 | padding: 0.375rem 0.75rem; 29 | border: 1px solid #DF691A; 30 | border-top: 0; 31 | grid-column: 1/3; 32 | 33 | hr { 34 | margin: 6px; 35 | } 36 | } 37 | } 38 | 39 | .button-page-icons { 40 | text-align: right; 41 | 42 | .page-image { 43 | filter: grayscale(100%) brightness(200%); 44 | height: 24px; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/src/app/data-services/wallet.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE } from '@ionaru/eve-utils'; 4 | 5 | import { Character } from '../models/character/character.model'; 6 | import { Scope } from '../pages/scopes/scopes.component'; 7 | import { BaseService } from './base.service'; 8 | 9 | @Injectable() 10 | export class WalletService extends BaseService { 11 | 12 | public async getWalletBalance(character: Character): Promise { 13 | BaseService.confirmRequiredScope(character, Scope.WALLET, 'getWalletBalance'); 14 | 15 | const url = EVE.getCharacterWalletUrl(character.characterId); 16 | const headers = new HttpHeaders({Authorization: character.getAuthorizationHeader()}); 17 | const response = await this.http.get(url, {headers}).toPromise().catch(this.catchHandler); 18 | if (response instanceof HttpErrorResponse) { 19 | return -1; 20 | } 21 | return response; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/app/http-interceptors/request-count.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { HttpHandler, HttpInterceptor, HttpRequest, HttpResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { tap } from 'rxjs/operators'; 4 | 5 | import { NavigationComponent } from '../navigation/navigation.component'; 6 | 7 | /** 8 | * Interceptor to count the amount of requests currently running. 9 | */ 10 | @Injectable() 11 | export class RequestCountInterceptor implements HttpInterceptor { 12 | 13 | private counter = 0; 14 | 15 | public intercept(request: HttpRequest, next: HttpHandler) { 16 | 17 | NavigationComponent.requestCounterUpdateEvent.next(++this.counter); 18 | 19 | return next.handle(request).pipe( 20 | tap((event) => { 21 | // There may be other events besides the response. 22 | if (event instanceof HttpResponse) { 23 | NavigationComponent.requestCounterUpdateEvent.next(--this.counter); 24 | } 25 | }), 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /server/src/routers/global.router.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import { StatusCodes } from 'http-status-codes'; 3 | 4 | import { BaseRouter } from './base.router'; 5 | 6 | export class GlobalRouter extends BaseRouter { 7 | 8 | public constructor() { 9 | super(); 10 | this.createRoute('all', '/', GlobalRouter.globalRoute); 11 | } 12 | 13 | /** 14 | * All requests to the server go through this router (except when fetching static files). 15 | */ 16 | private static globalRoute(request: Request, response: Response, next?: NextFunction): Response | void { 17 | 18 | if (!request.session) { 19 | return GlobalRouter.sendResponse(response, StatusCodes.BAD_REQUEST, 'NoSession'); 20 | } 21 | 22 | // Define the session user if it didn't exists already 23 | if (request.session && !request.session.user) { 24 | request.session.user = {}; 25 | } 26 | 27 | // Continue to the other routes 28 | if (next) { 29 | next(); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /server/src/routers/error.router.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node'; 2 | import { NextFunction, Request } from 'express'; 3 | import { StatusCodes } from 'http-status-codes'; 4 | 5 | import { BaseRouter, IResponse } from './base.router'; 6 | 7 | export class ErrorRouter extends BaseRouter { 8 | 9 | // noinspection JSUnusedLocalSymbols 10 | /** 11 | * Handle errors thrown in requests, show or hide the stacktrace in the response depending on the environment. 12 | */ 13 | public static errorRoute(error: Error, request: Request, response: IResponse, _next: NextFunction): void { 14 | 15 | response.route!.push('ErrorRouter'); 16 | response.status(StatusCodes.INTERNAL_SERVER_ERROR); 17 | 18 | process.stderr.write(`Error on ${request.method} ${request.originalUrl} -> ${error.stack}\n`); 19 | Sentry.captureException(error); 20 | 21 | const errorDetails = process.env.NODE_ENV === 'production' ? undefined : { error: error.stack }; 22 | ErrorRouter.sendResponse(response, StatusCodes.INTERNAL_SERVER_ERROR, 'InternalServerError', errorDetails); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/src/app/data-services/skillqueue.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE, ICharacterSkillQueueData } from '@ionaru/eve-utils'; 4 | 5 | import { Character } from '../models/character/character.model'; 6 | import { Scope } from '../pages/scopes/scopes.component'; 7 | import { BaseService } from './base.service'; 8 | 9 | @Injectable() 10 | export class SkillQueueService extends BaseService { 11 | 12 | public async getSkillQueue(character: Character): Promise { 13 | BaseService.confirmRequiredScope(character, Scope.SKILLQUEUE, 'getSkillQueue'); 14 | 15 | const url = EVE.getCharacterSkillQueueUrl(character.characterId); 16 | const headers = new HttpHeaders({Authorization: character.getAuthorizationHeader()}); 17 | const response = await this.http.get(url, {headers}).toPromise().catch(this.catchHandler); 18 | if (response instanceof HttpErrorResponse) { 19 | return []; 20 | } 21 | return response; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Jeroen Akkerman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/src/app/data-services/industry-jobs.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE, ICharacterIndustryJobsData } from '@ionaru/eve-utils'; 4 | 5 | import { Character } from '../models/character/character.model'; 6 | import { Scope } from '../pages/scopes/scopes.component'; 7 | import { BaseService } from './base.service'; 8 | 9 | @Injectable() 10 | export class IndustryJobsService extends BaseService { 11 | 12 | public async getIndustryJobs(character: Character): Promise { 13 | BaseService.confirmRequiredScope(character, Scope.JOBS, 'getIndustryJobs'); 14 | 15 | const url = EVE.getCharacterIndustryJobsUrl(character.characterId); 16 | const headers = new HttpHeaders({Authorization: character.getAuthorizationHeader()}); 17 | const response = await this.http.get(url, {headers}).toPromise().catch(this.catchHandler); 18 | if (response instanceof HttpErrorResponse) { 19 | return []; 20 | } 21 | return response; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/app/data-services/attributes.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE, ICharacterAttributesData } from '@ionaru/eve-utils'; 4 | 5 | import { Character } from '../models/character/character.model'; 6 | import { Scope } from '../pages/scopes/scopes.component'; 7 | import { BaseService } from './base.service'; 8 | 9 | @Injectable() 10 | export class AttributesService extends BaseService { 11 | 12 | public async getAttributes(character: Character): Promise { 13 | BaseService.confirmRequiredScope(character, Scope.SKILLS, 'getAttributes'); 14 | 15 | const url = EVE.getCharacterAttributesUrl(character.characterId); 16 | const headers = new HttpHeaders({Authorization: character.getAuthorizationHeader()}); 17 | const response = await this.http.get(url, {headers}).toPromise().catch(this.catchHandler); 18 | 19 | if (response instanceof HttpErrorResponse) { 20 | return; 21 | } 22 | 23 | return response; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/src/app/data-services/wallet-journal.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE, ICharacterWalletJournalData } from '@ionaru/eve-utils'; 4 | 5 | import { Character } from '../models/character/character.model'; 6 | import { Scope } from '../pages/scopes/scopes.component'; 7 | import { BaseService } from './base.service'; 8 | 9 | @Injectable() 10 | export class WalletJournalService extends BaseService { 11 | 12 | public async getWalletJournal(character: Character): Promise { 13 | BaseService.confirmRequiredScope(character, Scope.WALLET, 'getWalletJournal'); 14 | 15 | const url = EVE.getCharacterWalletJournalUrl(character.characterId); 16 | const headers = new HttpHeaders({Authorization: character.getAuthorizationHeader()}); 17 | const response = await this.http.get(url, {headers}).toPromise().catch(this.catchHandler); 18 | if (response instanceof HttpErrorResponse) { 19 | return []; 20 | } 21 | return response; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/app/models/industry/industry-graph.ts: -------------------------------------------------------------------------------- 1 | import { Edge, Node } from '@swimlane/ngx-graph'; 2 | 3 | export class IndustryGraph { 4 | public links: Edge[] = []; 5 | public nodes: Node[] = []; 6 | 7 | addLink(fromNode: number | string, toNode: number | string, amount: number) { 8 | let link = this.links.find((l) => l.source === fromNode.toString() && l.target === toNode.toString()); 9 | if (!link) { 10 | link = { 11 | source: fromNode.toString(), 12 | target: toNode.toString(), 13 | label: '0', 14 | }; 15 | this.links.push(link); 16 | } 17 | 18 | link.label = (Number(link.label) + amount).toString(); 19 | } 20 | 21 | public addNode(id: number | string, name: string, amount: number) { 22 | const nodeId = id.toString(); 23 | let node = this.nodes.find((n) => n.id === nodeId); 24 | if (!node) { 25 | node = {id: nodeId, label: nodeId.toString(), data: {}}; 26 | this.nodes.push(node); 27 | } 28 | node.label = name; 29 | node.data.amount = ((node.data.amount || 0) + amount); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/app/data-services/users.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | 4 | import { BaseService, IServerResponse } from './base.service'; 5 | 6 | export interface IUsersResponseCharacters { 7 | accessToken: string; 8 | characterId: number; 9 | id: number; 10 | isActive: boolean; 11 | name: string; 12 | ownerHash: string; 13 | refreshToken: string; 14 | scopes: string; 15 | tokenExpiry: Date; 16 | uuid: string; 17 | user: IUsersResponse; 18 | } 19 | 20 | export interface IUsersResponse { 21 | id: number; 22 | isAdmin: boolean; 23 | lastLogin: Date; 24 | timesLogin: number; 25 | username: string; 26 | uuid: string; 27 | } 28 | 29 | @Injectable() 30 | export class UsersService extends BaseService { 31 | 32 | public async getUsers() { 33 | const url = 'api/users'; 34 | const response = await this.http.get(url).toPromise>().catch(this.catchHandler); 35 | if (response instanceof HttpErrorResponse) { 36 | return undefined; 37 | } 38 | return response.data; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src/app/data-services/ship.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE, ICharacterShipData } from '@ionaru/eve-utils'; 4 | 5 | import { Character } from '../models/character/character.model'; 6 | import { Scope } from '../pages/scopes/scopes.component'; 7 | import { BaseService } from './base.service'; 8 | 9 | @Injectable() 10 | export class ShipService extends BaseService { 11 | 12 | public async getCurrentShip(character: Character): Promise<{ id: number, name: string }> { 13 | BaseService.confirmRequiredScope(character, Scope.SHIP_TYPE, 'getCurrentShip'); 14 | 15 | const url = EVE.getCharacterShipUrl(character.characterId); 16 | const headers = new HttpHeaders({Authorization: character.getAuthorizationHeader()}); 17 | const response = await this.http.get(url, {headers}).toPromise().catch(this.catchHandler); 18 | if (response instanceof HttpErrorResponse) { 19 | return {id: -1, name: 'Error'}; 20 | } 21 | return { 22 | id: response.ship_type_id, 23 | name: response.ship_name, 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/src/app/http-interceptors/index.ts: -------------------------------------------------------------------------------- 1 | import { HTTP_INTERCEPTORS } from '@angular/common/http'; 2 | 3 | import { EsiCachingInterceptor } from './esi-caching.interceptor'; 4 | import { EsiLanguageInterceptor } from './esi-language.interceptor'; 5 | import { EsiRetryInterceptor } from './esi-retry.interceptor'; 6 | import { EsiUserAgentInterceptor } from './esi-user-agent.interceptor'; 7 | import { EsiWarningInterceptor } from './esi-warning.interceptor'; 8 | import { RequestCountInterceptor } from './request-count.interceptor'; 9 | import { ServerTokenInterceptor } from './server-token.interceptor'; 10 | 11 | export const httpInterceptorProviders = [ 12 | {provide: HTTP_INTERCEPTORS, useClass: RequestCountInterceptor, multi: true}, 13 | {provide: HTTP_INTERCEPTORS, useClass: EsiCachingInterceptor, multi: true}, 14 | {provide: HTTP_INTERCEPTORS, useClass: EsiUserAgentInterceptor, multi: true}, 15 | {provide: HTTP_INTERCEPTORS, useClass: EsiWarningInterceptor, multi: true}, 16 | {provide: HTTP_INTERCEPTORS, useClass: ServerTokenInterceptor, multi: true}, 17 | {provide: HTTP_INTERCEPTORS, useClass: EsiLanguageInterceptor, multi: true}, 18 | {provide: HTTP_INTERCEPTORS, useClass: EsiRetryInterceptor, multi: true}, 19 | ]; 20 | -------------------------------------------------------------------------------- /client/src/app/http-interceptors/esi-warning.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { tap } from 'rxjs/operators'; 4 | 5 | /** 6 | * Interceptor to communicate that an ESI route is giving a warning (usually deprecation). 7 | */ 8 | @Injectable() 9 | export class EsiWarningInterceptor implements HttpInterceptor { 10 | 11 | constructor(private http: HttpClient) { } 12 | 13 | public intercept(request: HttpRequest, next: HttpHandler) { 14 | 15 | return next.handle(request).pipe( 16 | tap((event) => { 17 | // There may be other events besides the response. 18 | if (event instanceof HttpResponse && event.status === 200 && event.headers.has('warning')) { 19 | 20 | const warningText = event.headers.get('warning') as string; 21 | if (warningText.includes('199') || warningText.includes('299')) { 22 | this.http.post('sso/log-route-warning', {route: request.url, text: warningText}).toPromise().then(); 23 | } 24 | } 25 | }), 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /server/migrations/1577713483793-BlueprintModel.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | // noinspection JSUnusedGlobalSymbols 4 | export class BlueprintModel1577713483793 implements MigrationInterface { 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query('CREATE TABLE `blueprint` (`id` int NOT NULL AUTO_INCREMENT, `uuid` varchar(36) NOT NULL, `createdOn` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updatedOn` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `typeId` int NOT NULL, `materialEfficiency` int NOT NULL DEFAULT 0, `timeEfficiency` int NOT NULL DEFAULT 0, `isCopy` tinyint NOT NULL DEFAULT 0, `characterId` int NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB', undefined); 8 | await queryRunner.query('ALTER TABLE `blueprint` ADD CONSTRAINT `FK_7e712c145c33fe8276222c9c558` FOREIGN KEY (`characterId`) REFERENCES `character`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION', undefined); 9 | } 10 | 11 | public async down(queryRunner: QueryRunner): Promise { 12 | await queryRunner.query('ALTER TABLE `blueprint` DROP FOREIGN KEY `FK_7e712c145c33fe8276222c9c558`', undefined); 13 | await queryRunner.query('DROP TABLE `blueprint`', undefined); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /client/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint-angular", 5 | "tslint:latest", 6 | "tslint-sonarts" 7 | ], 8 | "rulesDirectory": [ 9 | "codelyzer" 10 | ], 11 | "rules": { 12 | "deprecation": { 13 | "severity": "warning" 14 | }, 15 | "no-submodule-imports": [ 16 | true, 17 | "rxjs", 18 | "@angular", 19 | "core-js", 20 | "zone.js" 21 | ], 22 | "max-line-length": [ 23 | true, 24 | 140 25 | ], 26 | "promise-function-async": true, 27 | "await-promise": true, 28 | "no-null-keyword": true, 29 | "no-unused-expression": [ 30 | true, 31 | "allow-new" 32 | ], 33 | "quotemark": [ 34 | true, 35 | "single" 36 | ], 37 | "trailing-comma": [ 38 | true, 39 | { 40 | "multiline": "always", 41 | "singleline": "never" 42 | } 43 | ], 44 | "variable-name": [ 45 | true, 46 | "ban-keywords", 47 | "check-format", 48 | "allow-leading-underscore" 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /client/src/shared/calc.helper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Several static helper functions for calculations. 3 | */ 4 | export class Calc { 5 | 6 | public static readonly maxIntegerValue = 0x7FFFFFFF; 7 | 8 | public static readonly second = 1000; 9 | public static readonly minute = 60000; 10 | public static readonly hour = 3600000; 11 | public static readonly day = 86400000; 12 | public static readonly week = 604800000; 13 | 14 | public static readonly partPercentage = (part: number, total: number) => (part / total) * 100; 15 | public static readonly profitPercentage = (old: number, newAmount: number) => ((newAmount - old) / old) * 100; 16 | 17 | public static readonly wholeSeconds = (duration: number) => Math.floor(duration / Calc.second); 18 | public static readonly wholeMinutes = (duration: number) => Math.floor(duration / Calc.minute); 19 | public static readonly wholeHours = (duration: number) => Math.floor(duration / Calc.hour); 20 | public static readonly wholeDays = (duration: number) => Math.floor(duration / Calc.day); 21 | public static readonly wholeWeeks = (duration: number) => Math.floor(duration / Calc.week); 22 | 23 | public static readonly secondsToMilliseconds = (seconds: number) => seconds * 1000; 24 | public static readonly millisecondsToSeconds = (milliseconds: number) => milliseconds / 1000; 25 | } 26 | -------------------------------------------------------------------------------- /client/src/_variables.scss: -------------------------------------------------------------------------------- 1 | $eve-gold: #96732b; 2 | $eve-white: #cfcfce; 3 | 4 | $eve-gold-omega: #ffcc00; 5 | 6 | $eve-ore-button-active: #958d21; 7 | $eve-ore-button-highlight: #6e691e; 8 | $eve-ore-button: #43431a; 9 | 10 | $eve-ore-body: #0b1216; 11 | $eve-ore-content: #151d23; 12 | 13 | $eve-skill-untrained: #444a4f; 14 | $eve-skill-trained: #d0d2d3; 15 | $eve-skill-training: #2f849e; 16 | 17 | $eve-text-color-bold: #ffffff; 18 | $eve-text-color: #c2c4c5; 19 | $eve-text-yellow: #ffff00; 20 | 21 | $eve-sec-10: #2fefef; 22 | $eve-sec-09: #48F0C0; 23 | $eve-sec-08: #00EF47; 24 | $eve-sec-07: #00F000; 25 | $eve-sec-06: #8FEF2F; 26 | $eve-sec-05: #EFEF00; 27 | $eve-sec-04: #D77700; 28 | $eve-sec-03: #F06000; 29 | $eve-sec-02: #F04800; 30 | $eve-sec-01: #D73000; 31 | $eve-sec-00: #F00000; 32 | 33 | $body-primary: #101010; 34 | $body-secondary: #171B23; 35 | 36 | $navbar-top-height: 46px; 37 | $navbar-side-width: 50px; 38 | $body-padding: 20px; 39 | 40 | $opacity: 0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100; 41 | 42 | $standard-font-size: 1rem; 43 | $standard-line-height: 1.42857143; 44 | 45 | // Overrides 46 | $warning: $eve-text-yellow; 47 | $body-bg: $body-primary; 48 | $body-color: $eve-text-color; 49 | 50 | $theme-colors: ( 51 | "ore": #6E4423, 52 | "ice": #6B838D, 53 | "gas": #9f658e, 54 | ); 55 | 56 | @import '../node_modules/bootswatch/dist/superhero/variables'; 57 | -------------------------------------------------------------------------------- /client/src/app/pages/refining-profit/refining-profit.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 7 | 10 | 13 |
14 |
15 |
16 | 17 |
18 | 19 | 23 | 24 |
25 | 26 |

Prices are based on 8.000m³ of ore and buying in Domain and selling in Amarr

27 | 28 |
29 | 30 | 31 | Calculating profit... 32 | 33 | -------------------------------------------------------------------------------- /client/src/app/shared/esi-request-cache.ts: -------------------------------------------------------------------------------- 1 | import { HttpHeaders, HttpResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | 4 | import { BaseService } from '../data-services/base.service'; 5 | 6 | interface ICache { 7 | [identifier: string]: ICacheData | undefined; 8 | } 9 | 10 | interface ICacheData { 11 | data: any; 12 | expiry: string; 13 | pages?: string; 14 | } 15 | 16 | @Injectable() 17 | export class ESIRequestCache { 18 | 19 | private static cache: ICache = {}; 20 | 21 | public static get(identifier: string): HttpResponse | void { 22 | 23 | const cachedData = ESIRequestCache.cache[identifier]; 24 | 25 | if (cachedData) { 26 | 27 | const expiryDate = new Date(cachedData.expiry); 28 | const now = new Date(); 29 | 30 | if (expiryDate > now) { 31 | return new HttpResponse({ 32 | body: cachedData.data, 33 | headers: new HttpHeaders().set(BaseService.pagesHeaderName, cachedData.pages || '1'), 34 | }); 35 | } 36 | } 37 | } 38 | 39 | public static put(identifier: string, data: any, expiry: string, pages?: string) { 40 | ESIRequestCache.cache[identifier] = { 41 | data, 42 | expiry, 43 | pages, 44 | }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/src/app/http-interceptors/esi-retry.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE } from '@ionaru/eve-utils'; 4 | 5 | import { Observable, throwError, timer } from 'rxjs'; 6 | import { mergeMap, retryWhen } from 'rxjs/operators'; 7 | 8 | export const genericRetryStrategy = () => (attempts: Observable) => { 9 | 10 | const scalingDuration = 1500; 11 | const maxRetryAttempts = 2; 12 | 13 | return attempts.pipe( 14 | mergeMap((error, i) => { 15 | const retryAttempt = i + 1; 16 | 17 | if (retryAttempt > maxRetryAttempts || error.status < 500) { 18 | return throwError(error); 19 | } 20 | 21 | return timer(retryAttempt * scalingDuration); 22 | }), 23 | ); 24 | }; 25 | 26 | /** 27 | * Interceptor to retry an ESI request when it fails with a 5XX error. 28 | */ 29 | @Injectable() 30 | export class EsiRetryInterceptor implements HttpInterceptor { 31 | 32 | public intercept(request: HttpRequest, next: HttpHandler) { 33 | 34 | // We only want to retry ESI calls. 35 | if (!request.url.includes(EVE.ESIURL)) { 36 | return next.handle(request); 37 | } 38 | 39 | return next.handle(request).pipe( 40 | retryWhen(genericRetryStrategy()), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /client/src/app/data-services/search.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { IUniverseNamesDataUnit } from '@ionaru/eve-utils'; 4 | 5 | import { BaseService, IServerResponse } from './base.service'; 6 | 7 | export type SearchType = 'type' | 'region' | 'system' | 'constellation'; 8 | 9 | interface ISearchCache { 10 | [key: string]: IUniverseNamesDataUnit; 11 | } 12 | 13 | @Injectable() 14 | export class SearchService extends BaseService { 15 | 16 | private static searchCache: ISearchCache = {}; 17 | 18 | public async search(q: string, searchType: SearchType) { 19 | 20 | const searchKey = `${searchType}:${q}`; 21 | if (SearchService.searchCache[searchKey]) { 22 | return SearchService.searchCache[searchKey]; 23 | } 24 | 25 | const response = await this.search$(q, searchType).toPromise().catch(this.catchHandler); 26 | if (response instanceof HttpErrorResponse) { 27 | return; 28 | } 29 | 30 | if (response.data) { 31 | SearchService.searchCache[searchKey] = response.data; 32 | } 33 | 34 | return response.data; 35 | } 36 | 37 | public search$(q: string, searchType: SearchType) { 38 | return this.http.get>(`https://search.spaceships.app/${searchType}/`, { 39 | params: {q}, 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | 4 | client: 5 | build: 6 | context: ./client 7 | args: 8 | - EVIE_ENV 9 | - EVIE_FA_TOKEN 10 | restart: unless-stopped 11 | ports: 12 | - "${EVIE_CLIENT_PORT:-80}:80" 13 | depends_on: 14 | - server 15 | 16 | server: 17 | build: ./server 18 | environment: 19 | - DEBUG 20 | - EVIE_DB_HOST 21 | - EVIE_DB_NAME 22 | - EVIE_DB_PASS 23 | - EVIE_DB_PORT 24 | - EVIE_DB_SSL_CA 25 | - EVIE_DB_SSL_CERT 26 | - EVIE_DB_SSL_KEY 27 | - EVIE_DB_SSL_REJECT 28 | - EVIE_DB_USER 29 | - EVIE_SERVER_PORT 30 | - EVIE_SESSION_KEY 31 | - EVIE_SESSION_SECRET 32 | - EVIE_SESSION_SECURE 33 | - EVIE_SSO_AUTH_CALLBACK 34 | - EVIE_SSO_AUTH_CLIENT 35 | - EVIE_SSO_AUTH_SECRET 36 | - EVIE_SSO_LOGIN_CALLBACK 37 | - EVIE_SSO_LOGIN_CLIENT 38 | - EVIE_SSO_LOGIN_SECRET 39 | - EVIE_SSO_APP_CALLBACK 40 | - EVIE_SSO_APP_CLIENT 41 | - EVIE_SSO_APP_SECRET 42 | ports: 43 | - "${EVIE_SERVER_PORT:-3731}" 44 | restart: unless-stopped 45 | volumes: 46 | - ${EVIE_DATA_VOLUME:-data-volume}:/app/data 47 | 48 | volumes: 49 | data-volume: 50 | -------------------------------------------------------------------------------- /client/src/app/data-services/skills.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE, ICharacterSkillsData, IUniverseTypeData } from '@ionaru/eve-utils'; 4 | 5 | import { Character } from '../models/character/character.model'; 6 | import { Scope } from '../pages/scopes/scopes.component'; 7 | import { BaseService, IServerResponse } from './base.service'; 8 | 9 | @Injectable() 10 | export class SkillsService extends BaseService { 11 | 12 | public async getSkillsData(character: Character): Promise { 13 | BaseService.confirmRequiredScope(character, Scope.SKILLS, 'getSkillsData'); 14 | 15 | const url = EVE.getCharacterSkillsUrl(character.characterId); 16 | const headers = new HttpHeaders({Authorization: character.getAuthorizationHeader()}); 17 | const response = await this.http.get(url, {headers}).toPromise().catch(this.catchHandler); 18 | if (response instanceof HttpErrorResponse) { 19 | return; 20 | } 21 | return response; 22 | } 23 | 24 | public async getAllSkills(): Promise { 25 | const url = 'data/skill-types'; 26 | const response = await this.http.get(url).toPromise>().catch(this.catchHandler); 27 | if (response instanceof HttpErrorResponse) { 28 | return; 29 | } 30 | return response.data; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/src/app/data-services/structures.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE, IUniverseStructureData } from '@ionaru/eve-utils'; 4 | 5 | import { Character } from '../models/character/character.model'; 6 | import { Scope } from '../pages/scopes/scopes.component'; 7 | import { BaseService } from './base.service'; 8 | 9 | interface IStructureCache { 10 | [key: number]: IUniverseStructureData | undefined; 11 | } 12 | 13 | @Injectable() 14 | export class StructuresService extends BaseService { 15 | 16 | private structureCache: IStructureCache = {}; 17 | 18 | public async getStructureInfo(character: Character, structureId: number): Promise { 19 | BaseService.confirmRequiredScope(character, Scope.STRUCTURES, 'getStructureInfo'); 20 | 21 | if (structureId in this.structureCache) { 22 | return this.structureCache[structureId]; 23 | } 24 | 25 | const url = EVE.getUniverseStructureUrl(structureId); 26 | const headers = new HttpHeaders({Authorization: character.getAuthorizationHeader()}); 27 | const response = await this.http.get(url, {headers}).toPromise().catch(this.catchHandler); 28 | if (response instanceof HttpErrorResponse) { 29 | this.structureCache[structureId] = undefined; 30 | return; 31 | } 32 | this.structureCache[structureId] = response; 33 | return response; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/src/app/pages/industry/system-overview/industry-system-overview.component.scss: -------------------------------------------------------------------------------- 1 | @import '../industry.component'; 2 | 3 | .character-adder { 4 | .character-adder-name, .character-adder-buttons { 5 | display: flex; 6 | align-items: center; 7 | } 8 | } 9 | 10 | .system-overview { 11 | 12 | .sec-00 { 13 | color: $eve-sec-00; 14 | } 15 | 16 | .sec-01 { 17 | color: $eve-sec-01; 18 | } 19 | 20 | .sec-02 { 21 | color: $eve-sec-02; 22 | } 23 | 24 | .sec-03 { 25 | color: $eve-sec-03; 26 | } 27 | 28 | .sec-04 { 29 | color: $eve-sec-04; 30 | } 31 | 32 | .sec-05 { 33 | color: $eve-sec-05; 34 | } 35 | 36 | .sec-06 { 37 | color: $eve-sec-06; 38 | } 39 | 40 | .sec-07 { 41 | color: $eve-sec-07; 42 | } 43 | 44 | .sec-08 { 45 | color: $eve-sec-08; 46 | } 47 | 48 | .sec-09 { 49 | color: $eve-sec-09; 50 | } 51 | 52 | .sec-10 { 53 | color: $eve-sec-10; 54 | } 55 | 56 | .job-info { 57 | 58 | margin: 1.25rem 0; 59 | width: 100%; 60 | 61 | .job-time { 62 | text-align: right; 63 | } 64 | } 65 | 66 | .accordion ::ng-deep .card { 67 | background-color: $body-secondary; 68 | 69 | .card-header { 70 | background-color: transparent; 71 | padding: 0; 72 | 73 | .btn.btn-link { 74 | color: $body-color; 75 | width: 100%; 76 | text-align: left; 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /client/src/app/http-interceptors/esi-caching.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { HttpHandler, HttpInterceptor, HttpRequest, HttpResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE } from '@ionaru/eve-utils'; 4 | import { of } from 'rxjs'; 5 | import { tap } from 'rxjs/operators'; 6 | 7 | import { BaseService } from '../data-services/base.service'; 8 | import { ESIRequestCache } from '../shared/esi-request-cache'; 9 | 10 | /** 11 | * Interceptor to cache ESI requests. 12 | */ 13 | @Injectable() 14 | export class EsiCachingInterceptor implements HttpInterceptor { 15 | 16 | public intercept(request: HttpRequest, next: HttpHandler) { 17 | 18 | // We only want to cache GET ESI calls. 19 | if (request.method !== 'GET' || !request.url.includes(EVE.ESIURL)) { 20 | return next.handle(request); 21 | } 22 | 23 | const cachedResponse = ESIRequestCache.get(request.urlWithParams); 24 | if (cachedResponse) { 25 | return of(cachedResponse); 26 | } 27 | 28 | return next.handle(request).pipe( 29 | tap((event) => { 30 | // Only cache when the response is successful and has an expiry header. 31 | if (event instanceof HttpResponse && event.status === 200 && event.headers.has('expires')) { 32 | 33 | ESIRequestCache.put( 34 | request.urlWithParams, 35 | event.body, 36 | event.headers.get('expires') as string, 37 | event.headers.get(BaseService.pagesHeaderName) || undefined, 38 | ); 39 | } 40 | }), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /client/src/app/data-services/types.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { IUniverseTypeData } from '@ionaru/eve-utils'; 4 | 5 | import { BaseService, IServerResponse } from './base.service'; 6 | 7 | @Injectable() 8 | export class TypesService extends BaseService { 9 | 10 | private typesCache: IUniverseTypeData[] = []; 11 | 12 | public async getTypes(...typeIds: number[]): Promise { 13 | const typesFromCache = this.typesCache.filter((type) => typeIds.includes(type.type_id)); 14 | 15 | if (typesFromCache.length === typeIds.length) { 16 | return typesFromCache; 17 | } 18 | 19 | const cachedTypeIds = typesFromCache.map((type) => type.type_id); 20 | const missingTypes = typeIds.filter((typeId) => !cachedTypeIds.includes(typeId)); 21 | 22 | const url = 'data/types'; 23 | const response = await this.http.post(url, missingTypes) 24 | .toPromise>() 25 | .catch(this.catchHandler); 26 | if (response instanceof HttpErrorResponse) { 27 | return undefined; 28 | } 29 | 30 | if (response.data) { 31 | this.typesCache.push(...response.data); 32 | this.typesCache = Array.from(new Set(this.typesCache)); 33 | } 34 | 35 | return response.data; 36 | } 37 | 38 | public async getType(typeId: number): Promise { 39 | const types = await this.getTypes(typeId); 40 | if (!types || !types.length) { 41 | return undefined; 42 | } 43 | return types[0]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client/src/app/pages/skills/skills.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables'; 2 | 3 | .skill-points-container { 4 | display: grid; 5 | grid-template-columns: repeat(2, auto); 6 | justify-content: space-between; 7 | 8 | .training-speed { 9 | text-align: right; 10 | } 11 | } 12 | 13 | .attributes-container { 14 | 15 | display: grid; 16 | grid-template-columns: repeat(5, auto); 17 | 18 | .attribute { 19 | display: grid; 20 | grid-template-columns: repeat(2, auto); 21 | 22 | p { 23 | margin: auto; 24 | } 25 | } 26 | } 27 | 28 | .skill-queue-time { 29 | &.low { 30 | color: $warning; 31 | } 32 | } 33 | 34 | .skill-queue { 35 | 36 | $skill-queue-left-margin: 20px; 37 | 38 | .stalled { 39 | color: $warning; 40 | } 41 | 42 | .skill-in-queue { 43 | margin-top: 10px; 44 | display: flex; 45 | justify-content: space-between; 46 | 47 | .right-info { 48 | text-align: right; 49 | } 50 | 51 | &.training { 52 | color: $primary; 53 | } 54 | 55 | .left-info .skill-info { 56 | margin-left: $skill-queue-left-margin; 57 | } 58 | } 59 | 60 | .skill-progressbar { 61 | margin-left: $skill-queue-left-margin; 62 | } 63 | } 64 | 65 | .skill-list { 66 | 67 | padding-top: 20px; 68 | 69 | .accordion ::ng-deep .card { 70 | background-color: $body-secondary; 71 | 72 | .card-header { 73 | background-color: transparent; 74 | padding: 0; 75 | 76 | .btn.btn-link { 77 | color: $body-color; 78 | width: 100%; 79 | text-align: left; 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /client/src/app/pages/dashboard/dashboard.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables'; 2 | 3 | .character-dashboard { 4 | .character-search { 5 | input { 6 | background: transparent; 7 | border-color: $primary; 8 | color: $primary; 9 | } 10 | } 11 | 12 | .character-container2 { 13 | display: flex; 14 | flex-direction: column; 15 | border: $primary 1px solid; 16 | margin-bottom: 5px; 17 | margin-top: 5px; 18 | background-color: darken($primary, 40%); 19 | max-width: 258px; 20 | 21 | .character-info { 22 | height: 100%; 23 | display: flex; 24 | flex-direction: column; 25 | justify-content: space-between; 26 | 27 | .character-link { 28 | transition: filter 0.5s ease-in-out, color 0.15s ease-in-out, background-color 0.15s ease-in-out; 29 | 30 | &:hover { 31 | background-color: $primary; 32 | } 33 | } 34 | 35 | p { 36 | margin: 0; 37 | padding: 5px; 38 | text-align: center; 39 | 40 | &.button-row { 41 | padding: 0; 42 | display: flex; 43 | 44 | button { 45 | border: none; 46 | width: 100%; 47 | transition: filter 0.5s ease-in-out, color 0.15s ease-in-out, background-color 0.15s ease-in-out; 48 | &.disabled, &:disabled { 49 | filter: grayscale(100%); 50 | } 51 | } 52 | } 53 | } 54 | } 55 | } 56 | 57 | .add-sort-buttons .btn { 58 | margin: 0 10px; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /client/src/app/data-services/skill-groups.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE, IUniverseCategoryData, IUniverseGroupData } from '@ionaru/eve-utils'; 4 | 5 | import { BaseService } from './base.service'; 6 | 7 | @Injectable() 8 | export class SkillGroupsService extends BaseService { 9 | 10 | public async getSkillGroupInformation(): Promise { 11 | const skillInfo: IUniverseGroupData[] = []; 12 | 13 | const skillCategory = await this.getSkillCategory(); 14 | 15 | if (!skillCategory) { 16 | return skillInfo; 17 | } 18 | 19 | const skillGroupIds = skillCategory.groups; 20 | 21 | await Promise.all(skillGroupIds.map(async (skillGroupId) => { 22 | const group = await this.getSkillGroup(skillGroupId); 23 | 24 | if (group && group.published) { 25 | skillInfo.push(group); 26 | } 27 | })); 28 | 29 | return skillInfo; 30 | } 31 | 32 | private async getSkillCategory(): Promise { 33 | const url = EVE.getUniverseCategoryUrl(EVE.skillCategoryId); 34 | const response = await this.http.get(url).toPromise().catch(this.catchHandler); 35 | if (response instanceof HttpErrorResponse) { 36 | return; 37 | } 38 | return response; 39 | } 40 | 41 | private async getSkillGroup(groupId: number): Promise { 42 | const url = EVE.getUniverseGroupUrl(groupId); 43 | const response = await this.http.get(url).toPromise().catch(this.catchHandler); 44 | if (response instanceof HttpErrorResponse) { 45 | return; 46 | } 47 | return response; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /client/src/app/guards/base.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NgZone } from '@angular/core'; 2 | import { CanActivate, Router } from '@angular/router'; 3 | import { Observable, Observer } from 'rxjs'; 4 | 5 | import { AppReadyEventService } from '../app-ready-event.service'; 6 | 7 | @Injectable() 8 | export class BaseGuard implements CanActivate { 9 | 10 | public static readonly redirectKey = 'redirect'; 11 | 12 | constructor(private router: Router, private ngZone: NgZone) { } 13 | 14 | public condition(): boolean { 15 | return false; 16 | } 17 | 18 | public navigateToHome() { 19 | this.ngZone.run(() => this.router.navigate(['/'])).then(); 20 | } 21 | 22 | // This guard will redirect to '/' when its condition is not met. 23 | public canActivate(): Observable | boolean { 24 | 25 | // Save path to redirect to after login. 26 | localStorage.setItem(BaseGuard.redirectKey, window.location.pathname); 27 | 28 | if (AppReadyEventService.appReady) { 29 | if (this.condition()) { 30 | localStorage.removeItem(BaseGuard.redirectKey); 31 | return true; 32 | } else { 33 | this.navigateToHome(); 34 | return false; 35 | } 36 | } else { 37 | return new Observable((observer: Observer) => { 38 | AppReadyEventService.appReadyEvent.subscribe(() => { 39 | if (this.condition()) { 40 | localStorage.removeItem(BaseGuard.redirectKey); 41 | observer.next(true); 42 | } else { 43 | this.navigateToHome(); 44 | observer.next(false); 45 | } 46 | observer.complete(); 47 | }); 48 | }); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /client/src/app/pages/prices-chart/prices-chart.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 7 | 11 | 15 |
16 | 17 |
18 | 22 |
23 | 24 |
25 | 29 | 33 |
34 |
35 | 36 |
37 | 38 | 39 |

Prices are based on buying/selling 5.000m³ of product in Jita.

40 | 41 |
42 | 43 | 44 | Loading... 45 | 46 | -------------------------------------------------------------------------------- /client/src/app/pages/home/home.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Welcome to EVIE!

4 |
5 |

6 | EVIE can be best described as an EVE Online API viewer, it enables you to view your 7 | EVE Online character information without having to log into the game. 8 |

9 |
10 |

11 | My goal is to provide you with a simple-to-use API viewer that gives you as much useful information about 12 | your characters as possible. 13 |

14 |
15 |

16 | This website uses SSO login from EVE Online. 17 | This will also enable you to save multiple characters and quickly access their data. 18 |
19 | The source code of this website is publicly available on 20 | GitHub, 21 | so feel free to nose around and discover how it all works. 22 |

23 |
24 |

Currently functional pages:

25 | 35 |
36 | 37 |
38 |
39 |
40 |
41 | 42 |
43 |
44 |
45 | -------------------------------------------------------------------------------- /client/src/app/pages/data-page/data-page.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from '@angular/core'; 2 | import * as countdown from 'countdown'; 3 | import { Subscription } from 'rxjs'; 4 | 5 | import { CharacterService } from '../../models/character/character.service'; 6 | import { UserService } from '../../models/user/user.service'; 7 | import { NavigationComponent } from '../../navigation/navigation.component'; 8 | 9 | @Component({ 10 | selector: 'app-base-page', 11 | template: '', 12 | }) 13 | export class DataPageComponent implements OnInit, OnDestroy { 14 | 15 | public missingAllRequiredScopes?: boolean; 16 | 17 | protected requiredScopes: string[] = []; 18 | 19 | private characterChangeSubscription: Subscription; 20 | private userChangeSubscription: Subscription; 21 | private serverStatusSubscription: Subscription; 22 | 23 | constructor() { 24 | this.userChangeSubscription = UserService.userChangeEvent.subscribe(() => { 25 | this.ngOnInit(); 26 | }); 27 | this.characterChangeSubscription = CharacterService.characterChangeEvent.subscribe(() => { 28 | this.ngOnInit(); 29 | }); 30 | this.serverStatusSubscription = NavigationComponent.serverStatusEvent.subscribe(() => { 31 | this.ngOnInit(); 32 | }); 33 | 34 | countdown.resetLabels(); 35 | countdown.resetFormat(); 36 | } 37 | 38 | public ngOnInit() { 39 | this.checkScopes(); 40 | } 41 | 42 | public ngOnDestroy() { 43 | this.userChangeSubscription.unsubscribe(); 44 | this.characterChangeSubscription.unsubscribe(); 45 | this.serverStatusSubscription.unsubscribe(); 46 | } 47 | 48 | public softReload() { 49 | this.ngOnDestroy(); 50 | this.ngOnInit(); 51 | } 52 | 53 | private checkScopes() { 54 | this.missingAllRequiredScopes = !this.requiredScopes.some((scope) => { 55 | if (CharacterService.selectedCharacter) { 56 | return CharacterService.selectedCharacter.hasScope(scope); 57 | } 58 | return false; 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /server/src/routers/user.router.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { StatusCodes } from 'http-status-codes'; 3 | 4 | import { Character } from '../models/character.model'; 5 | import { User } from '../models/user.model'; 6 | 7 | import { BaseRouter } from './base.router'; 8 | 9 | export class UserRouter extends BaseRouter { 10 | 11 | public constructor() { 12 | super(); 13 | this.createRoute('get', '/', UserRouter.getUsers); 14 | this.createRoute('get', '/:uuid([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', UserRouter.getUser); 15 | this.createRoute('get', '/:id([0-9])', UserRouter.getUserById); 16 | } 17 | 18 | @UserRouter.requestDecorator(UserRouter.checkAdmin) 19 | private static async getUsers(_request: Request, response: Response): Promise { 20 | const characters = await Character.doQuery() 21 | .innerJoinAndSelect('character.user', 'user') 22 | .orderBy('user.id') 23 | .getMany(); 24 | return UserRouter.sendSuccessResponse(response, characters); 25 | } 26 | 27 | @UserRouter.requestDecorator(UserRouter.checkAdmin) 28 | private static async getUser(request: Request, response: Response): Promise { 29 | const user: User | undefined = await User.findOne({uuid: request.params.uuid}); 30 | 31 | if (!user) { 32 | // No user with that username was found 33 | return UserRouter.sendResponse(response, StatusCodes.NOT_FOUND, 'UserNotFound'); 34 | } 35 | 36 | return UserRouter.sendSuccessResponse(response, user); 37 | } 38 | 39 | @UserRouter.requestDecorator(UserRouter.checkAdmin) 40 | private static async getUserById(request: Request, response: Response): Promise { 41 | const user: User | undefined = await User.findOne(request.params.id); 42 | 43 | if (!user) { 44 | // No user with that username was found 45 | return UserRouter.sendResponse(response, StatusCodes.NOT_FOUND, 'UserNotFound'); 46 | } 47 | 48 | return UserRouter.sendSuccessResponse(response, user); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /server/src/loggers/query.logger.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node'; 2 | import * as chalk from 'chalk'; 3 | import { Logger, QueryRunner } from 'typeorm'; 4 | 5 | import { debug } from '../index'; 6 | 7 | export class QueryLogger implements Logger { 8 | 9 | private static debug = debug.extend('query'); 10 | 11 | private static colorizeQuery(query: string): string { 12 | const queryWords = query.split(' '); 13 | const uppercaseRegex = new RegExp('^([A-Z]){2,}$'); 14 | for (const queryWord of queryWords) { 15 | if (uppercaseRegex.test(queryWord)) { 16 | queryWords[queryWords.indexOf(queryWord)] = chalk.blueBright(queryWord); 17 | } 18 | } 19 | return queryWords.join(' '); 20 | } 21 | 22 | private static getQueryText(query: string, parameters: any[] = []) { 23 | let output = QueryLogger.colorizeQuery(query); 24 | 25 | if (parameters.length) { 26 | const parametersText = `(${parameters})`; 27 | output += `; ${chalk.white(parametersText)}`; 28 | } 29 | 30 | return output; 31 | } 32 | 33 | public logQuery(query: string, parameters?: any[], _queryRunner?: QueryRunner): void { 34 | QueryLogger.debug(QueryLogger.getQueryText(query, parameters)); 35 | } 36 | 37 | public logQueryError(error: string, query: string, parameters?: any[], _queryRunner?: QueryRunner): void { 38 | process.stderr.write(error + '\n'); 39 | process.stderr.write(QueryLogger.getQueryText(query, parameters) + '\n'); 40 | Sentry.captureException(error); 41 | } 42 | 43 | public logQuerySlow(_time: number, _query: string, _parameters?: any[], _queryRunner?: QueryRunner): void { 44 | return undefined; 45 | } 46 | 47 | public logSchemaBuild(_message: string, _queryRunner?: QueryRunner): void { 48 | return undefined; 49 | } 50 | 51 | public logMigration(_message: string, _queryRunner?: QueryRunner): void { 52 | return undefined; 53 | } 54 | 55 | public log(_level: 'log' | 'info' | 'warn', _message: string, _queryRunner?: QueryRunner): void { 56 | return undefined; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /client/src/app/components/sor-table/sor-table.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 20 | 21 | 22 | 23 | 24 | 39 | 40 | 41 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |   14 | 15 |   16 | 17 | 18 | 19 |
25 | 26 | 27 | 28 | {{ getData(column, row) | number:column.pipeVar ? column.pipeVar : '1.0-0' }} 29 | 30 | 31 | {{ getData(column, row) | date:column.pipeVar ? column.pipeVar : 'yyyy-MM-dd HH:mm:ss' }} 32 | 33 | 34 | {{ getData(column, row) }} 35 | 36 | 37 | 38 |
42 | -------------------------------------------------------------------------------- /server/src/models/character.model.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne, OneToMany, SelectQueryBuilder } from 'typeorm'; 2 | 3 | import { BaseModel } from './base.model'; 4 | import { Blueprint } from './blueprint.model'; 5 | import { User } from './user.model'; 6 | 7 | export interface ISanitizedCharacter { 8 | uuid: string; 9 | isActive: boolean; 10 | accessToken?: string; 11 | } 12 | 13 | @Entity() 14 | export class Character extends BaseModel { 15 | 16 | @Column({ 17 | nullable: true, 18 | }) 19 | public name?: string; 20 | 21 | @Column({ 22 | nullable: true, 23 | }) 24 | public characterId?: number; 25 | 26 | @Column({ 27 | nullable: true, 28 | type: 'text', 29 | }) 30 | public accessToken?: string; 31 | 32 | @Column({ 33 | nullable: true, 34 | }) 35 | public tokenExpiry?: Date; 36 | 37 | @Column({ 38 | nullable: true, 39 | }) 40 | public refreshToken?: string; 41 | 42 | @Column({ 43 | nullable: true, 44 | type: 'text', 45 | }) 46 | public scopes?: string; 47 | 48 | @Column({ 49 | nullable: true, 50 | }) 51 | public ownerHash?: string; 52 | 53 | @Column({ 54 | default: false, 55 | type: Boolean, 56 | }) 57 | public isActive = false; 58 | 59 | @OneToMany(() => Blueprint, (blueprint) => blueprint.character) 60 | public blueprints!: Blueprint[]; 61 | 62 | @ManyToOne(() => User, (user) => user.characters, { 63 | onDelete: 'CASCADE', 64 | }) 65 | public user!: User; 66 | 67 | public get sanitizedCopy(): ISanitizedCharacter { 68 | return { 69 | accessToken: this.accessToken, 70 | isActive: this.isActive, 71 | uuid: this.uuid, 72 | }; 73 | } 74 | 75 | public static doQuery(): SelectQueryBuilder { 76 | return this.createQueryBuilder('character'); 77 | } 78 | 79 | public static async getFromId(id: number): Promise { 80 | return Character.doQuery() 81 | .innerJoinAndSelect('character.user', 'user') 82 | .where('character.characterId = :id', {id}) 83 | .getOne(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /server/ormconfig.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Manages the configuration settings for the TypeORM. 3 | */ 4 | 5 | const fs = require('fs'); 6 | 7 | const runningMigration = process.argv.length >= 3 && process.argv[2].includes('migration'); 8 | const runningTSMain = process.argv[1].includes('index.ts'); 9 | 10 | const models = [ 11 | 'blueprint.model', 12 | 'character.model', 13 | 'user.model', 14 | ]; 15 | 16 | const database = process.env.EVIE_DB_NAME; 17 | const host = process.env.EVIE_DB_HOST; 18 | const port = process.env.EVIE_DB_PORT; 19 | const username = process.env.EVIE_DB_USER; 20 | const password = process.env.EVIE_DB_PASS; 21 | 22 | const connectionOptions = { 23 | database, 24 | type: 'mysql', 25 | timezone: 'Z', 26 | host, 27 | port, 28 | username, 29 | password, 30 | }; 31 | 32 | if (process.env.EVIE_DB_SSL_CA && process.env.EVIE_DB_SSL_CERT && process.env.EVIE_DB_SSL_KEY) { 33 | const rejectUnauthorized = process.env.EVIE_DB_SSL_REJECT ? process.env.EVIE_DB_SSL_REJECT.toLowerCase() === 'true' : true; 34 | 35 | connectionOptions['ssl'] = { 36 | ca: fs.readFileSync(process.env.EVIE_DB_SSL_CA), 37 | cert: fs.readFileSync(process.env.EVIE_DB_SSL_CERT), 38 | key: fs.readFileSync(process.env.EVIE_DB_SSL_KEY), 39 | rejectUnauthorized, 40 | }; 41 | 42 | if (!rejectUnauthorized) { 43 | process.emitWarning('SSL connection to Database is not secure, \'db_reject\' should be true'); 44 | } 45 | } 46 | 47 | if (!connectionOptions.ssl && !['localhost', '0.0.0.0', '127.0.0.1'].includes(connectionOptions.host)) { 48 | process.emitWarning('Connection to Database is not secure, always use SSL to connect to external databases!'); 49 | } 50 | 51 | if (runningMigration || runningTSMain) { 52 | connectionOptions.entities = models.map((model) => `src/models/${model}.ts`); 53 | } else { 54 | connectionOptions.entities = models.map((model) => `dist/src/models/${model}.js`); 55 | } 56 | 57 | if (runningMigration) { 58 | connectionOptions.cli = { 59 | migrationsDir: 'migrations', 60 | }; 61 | connectionOptions.migrations = ['migrations/*.ts']; 62 | connectionOptions.migrationsTableName = 'migrations'; 63 | } 64 | 65 | module.exports = connectionOptions; 66 | -------------------------------------------------------------------------------- /client/src/app/pages/users/users.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Title } from '@angular/platform-browser'; 3 | 4 | import { ITableHeader } from '../../components/sor-table/sor-table.component'; 5 | import { UsersService } from '../../data-services/users.service'; 6 | import { createTitle } from '../../shared/title'; 7 | 8 | interface IUserData { 9 | id: number; 10 | name: string; 11 | characterId: number; 12 | timesLogin: number; 13 | lastLogin: Date; 14 | } 15 | 16 | @Component({ 17 | selector: 'app-users', 18 | styleUrls: ['./users.component.scss'], 19 | templateUrl: './users.component.html', 20 | }) 21 | export class UsersComponent implements OnInit { 22 | 23 | public users: IUserData[] = []; 24 | 25 | public tableSettings: ITableHeader[] = [{ 26 | attribute: 'id', 27 | sort: true, 28 | title: 'User ID', 29 | }, { 30 | attribute: 'name', 31 | prefixFunction: (data) => `${data.name} `, 32 | sort: true, 33 | }, { 34 | attribute: 'characterId', 35 | sort: true, 36 | title: 'Character ID', 37 | }, { 38 | attribute: 'timesLogin', 39 | sort: true, 40 | title: 'Times logged in', 41 | }, { 42 | attribute: 'lastLogin', 43 | pipe: 'date', 44 | pipeVar: 'yyyy-MM-dd HH:mm:ss', 45 | sort: true, 46 | title: 'Last login', 47 | }]; 48 | 49 | constructor(private usersService: UsersService, private title: Title) { } 50 | 51 | public ngOnInit() { 52 | this.title.setTitle(createTitle('Users')); 53 | this.usersService.getUsers().then((users) => { 54 | if (users) { 55 | this.users = users.map((user) => { 56 | return { 57 | id: user.user.id, 58 | name: user.name, 59 | characterId: user.characterId, 60 | timesLogin: user.user.timesLogin, 61 | lastLogin: user.user.lastLogin, 62 | }; 63 | }); 64 | } 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /client/src/app/data-services/blueprints.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse, HttpHeaders, HttpResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { generateNumbersArray } from '@ionaru/array-utils'; 4 | import { EVE, ICharacterBlueprintsData } from '@ionaru/eve-utils'; 5 | 6 | import { Character } from '../models/character/character.model'; 7 | import { Scope } from '../pages/scopes/scopes.component'; 8 | import { BaseService } from './base.service'; 9 | 10 | @Injectable() 11 | export class BlueprintsService extends BaseService { 12 | 13 | public async getBlueprints(character: Character): Promise { 14 | BaseService.confirmRequiredScope(character, Scope.BLUEPRINTS, 'getBlueprints'); 15 | 16 | const response = await this.getBlueprintsPage(character, 1); 17 | 18 | if (!response) { 19 | return []; 20 | } 21 | 22 | const blueprints = response.body || []; 23 | 24 | if (response.headers.has(BaseService.pagesHeaderName)) { 25 | const pages = Number(response.headers.get(BaseService.pagesHeaderName)); 26 | if (pages > 1) { 27 | const pageIterable = generateNumbersArray(pages, 2); 28 | 29 | await Promise.all(pageIterable.map(async (page) => { 30 | const pageResponse = await this.getBlueprintsPage(character, page); 31 | if (pageResponse && pageResponse.body) { 32 | blueprints.push(...pageResponse.body); 33 | } 34 | })); 35 | } 36 | } 37 | 38 | return blueprints; 39 | } 40 | 41 | public async getBlueprintsPage(character: Character, page: number) { 42 | const url = EVE.getCharacterBlueprintsUrl(character.characterId, page); 43 | const headers = new HttpHeaders({Authorization: character.getAuthorizationHeader()}); 44 | const response = await this.http.get( 45 | url, 46 | {headers, observe: 'response'}, 47 | ).toPromise>().catch(this.catchHandler); 48 | if (response instanceof HttpErrorResponse) { 49 | return; 50 | } 51 | 52 | return response; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /client/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpResponse } from '@angular/common/http'; 2 | import { Component } from '@angular/core'; 3 | 4 | import { environment } from '../environments/environment'; 5 | import { AppReadyEventService } from './app-ready-event.service'; 6 | import { BaseService, IServerResponse } from './data-services/base.service'; 7 | import { BaseGuard } from './guards/base.guard'; 8 | import { IUserApiData } from './models/user/user.model'; 9 | import { UserService } from './models/user/user.service'; 10 | import { NavigationComponent } from './navigation/navigation.component'; 11 | import { SocketService } from './socket/socket.service'; 12 | 13 | @Component({ 14 | selector: 'app-root', 15 | styleUrls: ['./app.component.scss'], 16 | templateUrl: './app.component.html', 17 | }) 18 | export class AppComponent { 19 | 20 | public readonly version = environment.VERSION; 21 | 22 | constructor( 23 | private appReadyEvent: AppReadyEventService, 24 | private http: HttpClient, 25 | private userService: UserService, 26 | ) { 27 | this.boot().then().catch((error) => this.appReadyEvent.triggerFailure('Error during app startup', error)); 28 | } 29 | 30 | private async boot(): Promise { 31 | localStorage.removeItem(BaseGuard.redirectKey); 32 | await this.shakeHands(); 33 | new SocketService(); 34 | SocketService.socket.on('STOP', (): void => { 35 | // The server will send STOP upon shutting down. 36 | // Reloading the window ensures nobody keeps using the site while the server is down. 37 | window.location.reload(); 38 | }); 39 | this.appReadyEvent.triggerSuccess(); 40 | } 41 | 42 | private async shakeHands(): Promise { 43 | const url = 'api/handshake'; 44 | 45 | const response = await this.http.get(url, {observe: 'response'}).toPromise>>(); 46 | 47 | BaseService.serverToken = response.headers.get('x-evie-token') || ''; 48 | 49 | if (response.body && response.body.message === 'LoggedIn' && response.body.data) { 50 | await this.userService.storeUser(response.body.data); 51 | } 52 | } 53 | 54 | // noinspection JSMethodCanBeStatic 55 | public get serverOnline() { 56 | return NavigationComponent.serverOnline; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /server/src/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, OneToMany, SelectQueryBuilder } from 'typeorm'; 2 | 3 | import { BaseModel } from './base.model'; 4 | import { Character, ISanitizedCharacter } from './character.model'; 5 | 6 | interface ISanitizedUser { 7 | characters: ISanitizedCharacter[]; 8 | isAdmin: boolean; 9 | uuid: string; 10 | } 11 | 12 | @Entity() 13 | export class User extends BaseModel { 14 | 15 | @Column({ 16 | default: false, 17 | type: Boolean, 18 | }) 19 | public isAdmin = false; 20 | 21 | @Column({ 22 | default: 1, 23 | type: Number, 24 | }) 25 | public timesLogin = 1; 26 | 27 | @Column({ 28 | default: () => 'CURRENT_TIMESTAMP', 29 | }) 30 | public lastLogin: Date = new Date(); 31 | 32 | @OneToMany(() => Character, (character) => character.user) 33 | public characters!: Character[]; 34 | 35 | public get sanitizedCopy(): ISanitizedUser { 36 | 37 | const characters = this.characters ? this.characters.map((character) => character.sanitizedCopy) : []; 38 | 39 | return { 40 | characters, 41 | isAdmin: this.isAdmin, 42 | uuid: this.uuid, 43 | }; 44 | } 45 | 46 | public static doQuery(): SelectQueryBuilder { 47 | return this.createQueryBuilder('user'); 48 | } 49 | 50 | public static async getFromId(id: number): Promise { 51 | return User.doQuery() 52 | .leftJoinAndSelect('user.characters', 'character') 53 | .where('user.id = :id', {id}) 54 | .getOne(); 55 | } 56 | 57 | public async merge(userToMerge: User): Promise { 58 | // Move Characters to new User 59 | await Character.doQuery() 60 | .update() 61 | .set({user: this}) 62 | .where('character.userId = :userId', {userId: userToMerge.id}) 63 | .execute(); 64 | 65 | // Copy relevant information from the old User to the new User. 66 | await User.doQuery() 67 | .update() 68 | .set({ 69 | isAdmin: userToMerge.isAdmin || this.isAdmin, 70 | timesLogin: userToMerge.timesLogin + this.timesLogin, 71 | }) 72 | .where('user.id = :id', {id: this.id}) 73 | .execute(); 74 | 75 | // Delete the old User 76 | User.delete(userToMerge.id).then(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /client/src/app/app-ready-event.service.ts: -------------------------------------------------------------------------------- 1 | import { DOCUMENT } from '@angular/common'; 2 | import { Inject, Injectable } from '@angular/core'; 3 | import * as Sentry from '@sentry/browser'; 4 | import { Observable, Observer } from 'rxjs'; 5 | 6 | import { environment } from '../environments/environment'; 7 | 8 | @Injectable() 9 | export class AppReadyEventService { 10 | 11 | private static _appReadyObserver: Observer; 12 | private static _appReadyEvent: Observable = new Observable((observer: Observer) => { 13 | AppReadyEventService._appReadyObserver = observer; 14 | }); 15 | public static get appReadyEvent() { return this._appReadyEvent; } 16 | 17 | private static _appReady = false; 18 | public static get appReady() { return this._appReady; } 19 | 20 | constructor(@Inject(DOCUMENT) private document: Document) { } 21 | 22 | public triggerSuccess(): void { 23 | // If the app-ready event has already been triggered, just ignore any calls to trigger it again. 24 | if (AppReadyEventService._appReady) { 25 | return; 26 | } 27 | 28 | AppReadyEventService._appReady = true; 29 | AppReadyEventService._appReadyObserver.next(undefined); 30 | AppReadyEventService._appReadyObserver.complete(); 31 | this.document.dispatchEvent(new CustomEvent('StartupSuccess')); 32 | } 33 | 34 | public triggerFailure(info = 'Unexpected error', detail: Error): void { 35 | // If the app-ready event has already been triggered, just ignore any calls to trigger it again. 36 | if (AppReadyEventService._appReady) { 37 | return; 38 | } 39 | 40 | // Fire StartupFailed first so the 'error-info' and 'error-info-detail' elements are created. 41 | this.document.dispatchEvent(new CustomEvent('StartupFailed')); 42 | 43 | const errorInfoElement = this.document.getElementById('error-info'); 44 | if (errorInfoElement) { 45 | errorInfoElement.innerText = info; 46 | } 47 | 48 | const errorInfoDetailElement = this.document.getElementById('error-info-detail'); 49 | if (errorInfoDetailElement) { 50 | errorInfoDetailElement.innerText = detail.message; 51 | } 52 | 53 | AppReadyEventService._appReady = true; 54 | 55 | if (environment.production) { 56 | Sentry.captureException(detail); 57 | } 58 | 59 | throw detail; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.github/workflows/cd.yaml: -------------------------------------------------------------------------------- 1 | name: EVIE CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - master 12 | 13 | jobs: 14 | client-test: 15 | 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v1 20 | 21 | - name: Set up Node.js 22 | uses: actions/setup-node@v2 23 | with: 24 | node-version: '16' 25 | 26 | - name: Audit 27 | working-directory: client 28 | env: 29 | EVIE_FA_TOKEN: ${{ secrets.FA_TOKEN }} 30 | run: npm audit --omit=dev 31 | 32 | - name: Install packages 33 | working-directory: client 34 | env: 35 | EVIE_FA_TOKEN: ${{ secrets.FA_TOKEN }} 36 | run: npm install 37 | 38 | - name: Run tests 39 | working-directory: client 40 | env: 41 | EVIE_FA_TOKEN: ${{ secrets.FA_TOKEN }} 42 | run: npm test 43 | 44 | server-test: 45 | 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Checkout 49 | uses: actions/checkout@v1 50 | 51 | - name: Set up Node.js 52 | uses: actions/setup-node@v2 53 | with: 54 | node-version: '14' 55 | 56 | - name: Audit 57 | working-directory: server 58 | run: npm audit --omit=dev 59 | 60 | - name: Install packages 61 | working-directory: server 62 | run: npm install 63 | 64 | - name: Run tests 65 | working-directory: server 66 | run: npm test 67 | 68 | deploy: 69 | 70 | needs: [client-test, server-test] 71 | runs-on: ubuntu-latest 72 | if: github.event_name == 'push' 73 | steps: 74 | - name: Set up Node.js 75 | uses: actions/setup-node@v2 76 | with: 77 | node-version: '16' 78 | 79 | - name: Deploy to dev.spaceships.app 80 | if: startsWith(github.ref, 'refs/heads/') 81 | run: npx -q @ionaru/teamcity-deploy teamcity.saturnserver.org Evie_BuildDev ${{ secrets.API_TOKEN }} 82 | 83 | - name: Deploy to spaceships.app 84 | if: startsWith(github.ref, 'refs/tags/') 85 | run: npx -q @ionaru/teamcity-deploy teamcity.saturnserver.org Evie_BuildProd ${{ secrets.API_TOKEN }} 86 | -------------------------------------------------------------------------------- /server/src/controllers/socket.controller.ts: -------------------------------------------------------------------------------- 1 | import { WebServer } from '@ionaru/web-server'; 2 | import * as express from 'express'; 3 | import { Session, SessionData } from 'express-session'; 4 | import { Server, Socket } from 'socket.io'; 5 | import * as SocketIOSession from 'socket.io-express-session'; 6 | import { Handshake } from 'socket.io/dist/socket'; 7 | 8 | import { debug } from '../index'; 9 | 10 | interface IHandshakeUnsure extends Handshake { 11 | session?: Session & SessionData; 12 | } 13 | 14 | interface IHandshake extends IHandshakeUnsure { 15 | session: Session & SessionData; 16 | } 17 | 18 | interface ISessionSocket extends Socket { 19 | handshake: T; 20 | } 21 | 22 | export class SocketServer { 23 | 24 | public static sockets: Array> = []; 25 | 26 | private static debug = debug.extend('socket'); 27 | 28 | public io: Server; 29 | 30 | public constructor(webServer: WebServer, sessionParser: express.RequestHandler) { 31 | 32 | // Pass the HTTP server to SocketIO for configuration. 33 | this.io = new Server(webServer.server, { 34 | // SocketIO cookie is unused: https://github.com/socketio/socket.io/issues/2276#issuecomment-147184662 35 | cookie: false, 36 | }); 37 | 38 | // The websocket server listens on '/' 39 | const socketServer = this.io.of('/'); 40 | 41 | // The websocket server needs the sessionParser to parse... sessions! 42 | socketServer.use(SocketIOSession(sessionParser)); 43 | 44 | // On connection with a client, save the socket ID to the client session and add it to the list of connected sockets 45 | socketServer.on('connection', async (socket: ISessionSocket) => { 46 | const session = socket.handshake.session; 47 | if (!session) { 48 | throw new Error('No socket session'); 49 | } 50 | 51 | session.socket = socket.id; 52 | session.save(() => undefined); 53 | SocketServer.debug(`Socket connect: ${socket.id}, session ${session.id}, namespace: ${socket.nsp.name}`); 54 | SocketServer.sockets.push(socket as ISessionSocket); 55 | 56 | // Remove the socket from the socket list when a client disconnects 57 | socket.on('disconnect', async () => { 58 | SocketServer.debug(`Socket disconnect: ${socket.id}, session ${session.id}`); 59 | SocketServer.sockets.splice(SocketServer.sockets.indexOf(socket as ISessionSocket), 1); 60 | }); 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /server/src/routers/api.router.ts: -------------------------------------------------------------------------------- 1 | import { generateRandomString } from '@ionaru/random-string'; 2 | import { Request, Response } from 'express'; 3 | import { StatusCodes } from 'http-status-codes'; 4 | 5 | import { User } from '../models/user.model'; 6 | 7 | import { BaseRouter } from './base.router'; 8 | 9 | export class APIRouter extends BaseRouter { 10 | 11 | public constructor() { 12 | super(); 13 | this.createRoute('get', '/handshake', APIRouter.doHandShake); 14 | this.createRoute('post', '/logout', APIRouter.logoutUser); 15 | } 16 | 17 | /** 18 | * Request that will return the user session, this is used when the client first loads. 19 | * path: /api/handshake 20 | * method: GET 21 | * returns: 22 | * 200 LoggedIn: The client has an active session 23 | * 200 NotLoggedIn: No client session was found 24 | */ 25 | private static async doHandShake(request: Request, response: Response): Promise { 26 | 27 | request.session.token = generateRandomString(10); 28 | response.setHeader('x-evie-token', request.session.token); 29 | 30 | if (!request.session.user!.id) { 31 | return APIRouter.sendSuccessResponse(response); 32 | } 33 | 34 | const user: User | undefined = await User.doQuery() 35 | .leftJoinAndSelect('user.characters', 'character') 36 | .where('user.id = :id', { id: request.session.user!.id }) 37 | .getOne(); 38 | 39 | if (!user) { 40 | // No user found that matches the ID in the session. 41 | delete request.session.user!.id; 42 | return APIRouter.sendSuccessResponse(response); 43 | } 44 | 45 | user.timesLogin++; 46 | user.lastLogin = new Date(); 47 | user.save().then(); 48 | 49 | return APIRouter.sendResponse(response, StatusCodes.OK, 'LoggedIn', user.sanitizedCopy); 50 | } 51 | 52 | /** 53 | * Destroy the user session. 54 | * path: /api/logout 55 | * method: POST 56 | */ 57 | private static async logoutUser(request: Request, response: Response): Promise { 58 | 59 | User.doQuery() 60 | .leftJoinAndSelect('user.characters', 'character') 61 | .where('user.id = :id', { id: request.session.user!.id }) 62 | .getOne().then((user) => { 63 | if (user && (!user.characters || !user.characters.length)) { 64 | user.remove().then(); 65 | } 66 | }); 67 | 68 | request.session.destroy(() => { 69 | response.end(); 70 | }); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "evie-client", 3 | "version": "0.7.13", 4 | "author": "Jeroen Akkerman", 5 | "description": "Server module for EVIE", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Ionaru/EVIE.git" 9 | }, 10 | "scripts": { 11 | "postinstall": "ngcc", 12 | "ng": "ng", 13 | "ts": "tsc -v", 14 | "start": "ng serve --host=0.0.0.0 --proxy-config proxy.conf.ts --source-map --port 4200 --watch --aot", 15 | "lint": "echo \"Error: no linter specified\" && exit 0", 16 | "pretest": "npm run lint", 17 | "test": "echo \"Error: no test specified\" && exit 0", 18 | "posttest": "ng build --prod", 19 | "build": "ng build", 20 | "build:prod": "ng build --prod", 21 | "preversion": "npm run test" 22 | }, 23 | "private": true, 24 | "dependencies": { 25 | "@angular/animations": "^11.0.7", 26 | "@angular/cdk": "^11.0.3", 27 | "@angular/common": "^11.0.7", 28 | "@angular/compiler": "^11.0.7", 29 | "@angular/core": "^11.0.7", 30 | "@angular/forms": "^11.0.7", 31 | "@angular/localize": "^11.0.7", 32 | "@angular/platform-browser": "^11.0.7", 33 | "@angular/platform-browser-dynamic": "^11.0.7", 34 | "@angular/router": "^11.0.7", 35 | "@fortawesome/angular-fontawesome": "^0.8.1", 36 | "@fortawesome/fontawesome-svg-core": "^1.2.32", 37 | "@fortawesome/pro-regular-svg-icons": "^5.15.1", 38 | "@fortawesome/pro-solid-svg-icons": "^5.15.1", 39 | "@ionaru/array-utils": "^5.0.0", 40 | "@ionaru/eve-utils": "^8.0.0", 41 | "@ionaru/romanize": "^2.0.1", 42 | "@ng-bootstrap/ng-bootstrap": "^9.0.0", 43 | "@sentry/browser": "^5.29.2", 44 | "@swimlane/ngx-graph": "8.0.0-rc.1", 45 | "bootstrap": "^4.5.3", 46 | "bootswatch": "^4.5.3", 47 | "countdown": "^2.6.0", 48 | "d3-dispatch": "^2.0.0", 49 | "d3-drag": "^2.0.0", 50 | "d3-scale": "^2.0.0", 51 | "d3-timer": "^2.0.0", 52 | "jwt-decode": "^3.1.2", 53 | "rxjs": "^6.6.0", 54 | "socket.io-client": "^3.0.5", 55 | "tslib": "^2.1.0", 56 | "zone.js": "~0.10.3" 57 | }, 58 | "devDependencies": { 59 | "@angular-devkit/build-angular": "^0.1100.6", 60 | "@angular/cli": "^11.0.6", 61 | "@angular/compiler-cli": "^11.0.7", 62 | "@angular/language-service": "^11.0.7", 63 | "@types/countdown": "0.0.7", 64 | "@types/d3-dispatch": "^2.0.0", 65 | "@types/d3-drag": "^2.0.0", 66 | "@types/d3-scale": "^2.0.0", 67 | "@types/d3-timer": "^2.0.0", 68 | "ts-node": "^9.1.1", 69 | "typescript": "^4.0.0" 70 | }, 71 | "license": "MIT" 72 | } 73 | -------------------------------------------------------------------------------- /client/src/app/pages/about/about.component.html: -------------------------------------------------------------------------------- 1 |
2 |

What is EVIE?

3 |

EVIE is an online API interface for the game EVE Online. It uses the game's exposed ESI API to display 4 | information about characters, the in-game market and the game itself.

5 |

Created by Ionaru. in-game: Ionaru Otsada. 6 |

7 |
8 |
9 |
10 |

Technologies used

11 |

Thank you all for making this project possible.

12 |
13 |
14 |

Frontend

15 |

Angular

16 |

Bootstrap

17 |

Bootswatch

18 |

FontAwesome

19 |

RxJS

20 |
21 |
22 |

Backend

23 |

Axios

24 |

Express

25 |

MySQL

26 |

Node.js

27 |

TypeORM

28 |
29 |
30 |
31 |

Special thanks to

32 |

Docker

33 |

ESLint

34 |

Git

35 |

GitHub

36 |

IntelliJ IDEA

37 |

npm

38 |

Sentry

39 |

Socket.IO

40 |

TeamCity

41 |

TSLint

42 |

TypeScript

43 |
44 |
45 |
46 |
47 |

Source code available on https://github.com/Ionaru/EVIE

49 |
50 | -------------------------------------------------------------------------------- /server/src/controllers/database.controller.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | import { createPool, Pool, PoolConfig } from 'mysql'; 4 | import { Connection, createConnection, getConnectionOptions } from 'typeorm'; 5 | 6 | import { debug } from '../index'; 7 | import { QueryLogger } from '../loggers/query.logger'; 8 | 9 | export let db: DatabaseConnection; 10 | 11 | export class DatabaseConnection { 12 | 13 | private static debug = debug.extend('database'); 14 | 15 | public pool?: Pool; 16 | public orm?: Connection; 17 | 18 | private readonly dbOptions: PoolConfig; 19 | 20 | public constructor() { 21 | const database = process.env.EVIE_DB_NAME; 22 | const host = process.env.EVIE_DB_HOST; 23 | const password = process.env.EVIE_DB_PASS; 24 | const port = Number(process.env.EVIE_DB_PORT); 25 | const user = process.env.EVIE_DB_USER; 26 | 27 | this.dbOptions = { 28 | database, 29 | host, 30 | password, 31 | port, 32 | user, 33 | }; 34 | 35 | const rejectUnauthorized = process.env.EVIE_DB_SSL_REJECT ? process.env.EVIE_DB_SSL_REJECT.toLowerCase() === 'true' : true; 36 | 37 | if (process.env.EVIE_DB_SSL_CA && process.env.EVIE_DB_SSL_CERT && process.env.EVIE_DB_SSL_KEY) { 38 | this.dbOptions.ssl = { 39 | ca: fs.readFileSync(process.env.EVIE_DB_SSL_CA), 40 | cert: fs.readFileSync(process.env.EVIE_DB_SSL_CERT), 41 | key: fs.readFileSync(process.env.EVIE_DB_SSL_KEY), 42 | rejectUnauthorized, 43 | }; 44 | } 45 | 46 | // eslint-disable-next-line @typescript-eslint/no-this-alias 47 | db = this; 48 | } 49 | 50 | public async connect(): Promise { 51 | DatabaseConnection.debug(`Connecting to ${process.env.EVIE_DB_HOST}:${process.env.EVIE_DB_PORT}/${process.env.EVIE_DB_NAME}`); 52 | 53 | await new Promise((resolve) => { 54 | this.pool = createPool(this.dbOptions); 55 | 56 | this.pool.on('connection', () => { 57 | resolve(); 58 | }); 59 | 60 | this.pool.on('error', (err) => { 61 | throw err; 62 | }); 63 | 64 | this.pool.getConnection((err) => { 65 | if (err) { 66 | throw err; 67 | } 68 | }); 69 | }); 70 | 71 | const connectionOptions = await getConnectionOptions(); 72 | 73 | Object.assign(connectionOptions, { 74 | logger: new QueryLogger(), 75 | logging: ['query', 'error'], 76 | }); 77 | 78 | this.orm = await createConnection(connectionOptions); 79 | 80 | DatabaseConnection.debug('Database connected'); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /client/src/app/pages/scopes/scopes.component.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | Choose scopes 4 |

5 |

To display your data from EVE Online, EVIE requires permission to use your 'scopes'.

6 |
7 |

What are scopes?

8 |

Scopes are certain sets of data that a third-party application, like EVIE, can access from your character.

9 |

For example you can grant access to view your skill queue, but not your wallet balance.

10 | 11 |
12 | 13 |
14 |
15 | 20 |
21 |
22 | 23 |
24 | 25 | 26 |
27 |
28 | 38 |
39 | 42 |
43 |
{{ scope.code }}
44 | "{{ scope.eveDescription }}" 45 |
46 | {{ scope.usageDescription }} 47 |
48 |
49 | 50 | 51 |
52 |
53 |
54 | 55 |
56 | 57 | 61 | 62 |
63 | -------------------------------------------------------------------------------- /client/src/app/data-services/names.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { uniquifyArray } from '@ionaru/array-utils'; 4 | import { EVE, IUniverseNamesData, IUniverseNamesDataUnit } from '@ionaru/eve-utils'; 5 | 6 | import { Calc } from '../../shared/calc.helper'; 7 | import { BaseService } from './base.service'; 8 | 9 | export interface INames { 10 | [id: string]: IUniverseNamesDataUnit; 11 | } 12 | 13 | @Injectable() 14 | export class NamesService extends BaseService { 15 | 16 | private static names: INames = {}; 17 | 18 | public static getNameFromData(id: number, unknownMessage = 'Unknown'): string { 19 | if (!NamesService.names || !Object.entries(NamesService.names).length) { 20 | return unknownMessage; 21 | } 22 | 23 | if (NamesService.names[id] && NamesService.names[id].name) { 24 | return NamesService.names[id].name; 25 | } else { 26 | return unknownMessage; 27 | } 28 | } 29 | 30 | public async getNames(...ids: (string | number)[]): Promise { 31 | 32 | const uniqueIds = uniquifyArray(ids.map((id) => id.toString())); 33 | 34 | for (const element of uniqueIds) { 35 | const elementNumber = Number(element); 36 | if (isNaN(elementNumber) || elementNumber > Calc.maxIntegerValue) { 37 | throw new Error(`${element} is not a value that can get resolved to a name.`); 38 | } 39 | } 40 | 41 | // Check if all values in 'ids' are -1, if so then there's no point in calling the Names Endpoint 42 | if (uniqueIds.every((element) => Number(element) === -1)) { 43 | return; 44 | } 45 | 46 | const namesToGet: string[] = []; 47 | 48 | for (const id of uniqueIds) { 49 | if (!NamesService.names[id]) { 50 | namesToGet.push(id); 51 | } 52 | } 53 | 54 | if (!namesToGet.length) { 55 | return; 56 | } 57 | 58 | const maxChunkSize = 1000; 59 | while (true) { 60 | const namesToGetChunk = namesToGet.splice(0, maxChunkSize); 61 | 62 | if (namesToGetChunk.length > 0) { 63 | await this.getNamesFromAPI(namesToGetChunk); 64 | } 65 | 66 | if (namesToGetChunk.length < 1000) { 67 | break; 68 | } 69 | } 70 | } 71 | 72 | private async getNamesFromAPI(ids: string[]): Promise { 73 | const url = EVE.getUniverseNamesUrl(); 74 | const response = await this.http.post(url, ids).toPromise().catch(this.catchHandler); 75 | if (response instanceof HttpErrorResponse) { 76 | return; 77 | } 78 | for (const name of response) { 79 | NamesService.names[name.id] = name; 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /client/src/app/components/sor-table/sor-table.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; 2 | import { faInfoCircle, faSort, faSortDown, faSortUp } from '@fortawesome/pro-solid-svg-icons'; 3 | import { sortArrayByObjectProperty } from '@ionaru/array-utils'; 4 | 5 | export interface ITableData { 6 | [index: string]: any; 7 | } 8 | 9 | export interface ITableHeader { 10 | attribute: keyof T; 11 | attributeFunction?: (value: T) => any; 12 | classFunction?: (value: T) => string; 13 | hint?: string; 14 | pipe?: 'number' | 'date'; 15 | pipeVar?: string; 16 | prefix?: string; 17 | prefixFunction?: (value: T) => string; 18 | sort?: boolean; 19 | sortAttribute?: keyof T; 20 | suffix?: string; 21 | suffixFunction?: (value: T) => string; 22 | title?: string; 23 | titleClass?: string; 24 | } 25 | 26 | @Component({ 27 | selector: 'app-sor-table', 28 | styleUrls: ['./sor-table.component.scss'], 29 | templateUrl: './sor-table.component.html', 30 | }) 31 | export class SorTableComponent implements OnChanges { 32 | 33 | @Input() public columns: ITableHeader[] = []; 34 | @Input() public data?: ITableData[]; 35 | 36 | public currentSort?: ITableHeader; 37 | public invert = false; 38 | 39 | public sortAscendingIcon = faSortUp; 40 | public sortDescendingIcon = faSortDown; 41 | public noSortIcon = faSort; 42 | public hintIcon = faInfoCircle; 43 | 44 | public getData(column: ITableHeader, data: ITableData) { 45 | return column.attributeFunction ? column.attributeFunction(data) : data[column.attribute]; 46 | } 47 | 48 | public sort(column = this.currentSort) { 49 | if (!column || !this.data) { 50 | return; 51 | } 52 | 53 | const sortAttribute = column.sortAttribute || column.attribute; 54 | 55 | this.invert = (this.currentSort && this.currentSort === column) ? !this.invert : false; 56 | 57 | sortArrayByObjectProperty(this.data, (item) => item[sortAttribute], this.invert); 58 | this.currentSort = column; 59 | } 60 | 61 | public getClass(column: ITableHeader, data: ITableData) { 62 | return column.classFunction ? column.classFunction(data) : ''; 63 | } 64 | 65 | public prefixFunction(column: ITableHeader, data: ITableData) { 66 | return column.prefixFunction ? column.prefixFunction(data) : undefined; 67 | } 68 | 69 | public suffixFunction(column: ITableHeader, data: ITableData) { 70 | return column.suffixFunction ? column.suffixFunction(data) : undefined; 71 | } 72 | 73 | public ngOnChanges(change: SimpleChanges) { 74 | if (change.data) { 75 | this.invert = !this.invert; 76 | this.sort(); 77 | } 78 | } 79 | 80 | public capitalize(key: keyof ITableData): string { 81 | const name = key.toString(); 82 | return name.charAt(0).toUpperCase() + name.slice(1); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "evie-server", 3 | "version": "0.7.13", 4 | "author": "Jeroen Akkerman", 5 | "description": "Server module for EVIE", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Ionaru/EVIE.git" 9 | }, 10 | "scripts": { 11 | "typeorm": "node --require ts-node/register node_modules/typeorm/cli.js", 12 | "migrate": "npm run typeorm migration:run", 13 | "clean": "npx -q rimraf dist", 14 | "build": "npm run clean && tsc --project tsconfig.json", 15 | "lint": "eslint --ext ts --max-warnings 0 src", 16 | "pretest": "npm run lint", 17 | "test": "echo \"Error: no test specified\" && exit 0", 18 | "posttest": "tsc --project tsconfig.json --noEmit", 19 | "start": "npm run migrate && node ./dist/src/index.js", 20 | "preversion": "npm run test" 21 | }, 22 | "private": true, 23 | "dependencies": { 24 | "@ionaru/esi-service": "^6.0.0", 25 | "@ionaru/eve-utils": "^8.0.0", 26 | "@ionaru/random-string": "^4.0.0", 27 | "@ionaru/web-server": "^4.0.0", 28 | "@sentry/node": "^6.15.0", 29 | "agentkeepalive": "^4.1.3", 30 | "app-root-path": "^3.0.0", 31 | "axios": "^0.24.0", 32 | "body-parser": "^1.19.0", 33 | "chalk": "^4.1.0", 34 | "compression": "^1.7.3", 35 | "cors": "^2.8.5", 36 | "debug": "^4.2.0", 37 | "express": "^4.17.1", 38 | "express-mysql-session": "^2.1.4", 39 | "express-session": "^1.17.1", 40 | "http-status-codes": "^2.1.4", 41 | "jsonwebtoken": "^8.3.0", 42 | "mysql": "^2.18.1", 43 | "on-finished": "^2.3.0", 44 | "reflect-metadata": "^0.1.13", 45 | "socket.io": "^4.4.0", 46 | "socket.io-express-session": "^0.1.3", 47 | "source-map-support": "^0.5.19", 48 | "typeorm": "^0.2.29" 49 | }, 50 | "devDependencies": { 51 | "@ionaru/eslint-config": "^5.0.1", 52 | "@types/app-root-path": "^1.2.4", 53 | "@types/bcryptjs": "^2.4.2", 54 | "@types/body-parser": "^1.19.0", 55 | "@types/component-emitter": "^1.2.10", 56 | "@types/compression": "^1.7.0", 57 | "@types/cookie": "^0.4.0", 58 | "@types/cors": "^2.8.8", 59 | "@types/debug": "^4.1.5", 60 | "@types/express": "^4.17.9", 61 | "@types/express-mysql-session": "^2.1.2", 62 | "@types/express-serve-static-core": "^4.17.13", 63 | "@types/express-session": "^1.17.2", 64 | "@types/jsonwebtoken": "^8.5.0", 65 | "@types/mysql": "^2.15.14", 66 | "@types/node": "^16.10.2", 67 | "@types/on-finished": "^2.3.1", 68 | "@types/source-map-support": "^0.5.2", 69 | "@typescript-eslint/eslint-plugin": "^5.4.0", 70 | "eslint": "^7.13.0", 71 | "eslint-plugin-import": "^2.24.2", 72 | "eslint-plugin-jest": "^25.3.0", 73 | "eslint-plugin-no-null": "^1.0.2", 74 | "eslint-plugin-prefer-arrow": "^1.2.3", 75 | "eslint-plugin-sonarjs": "^0.10.0", 76 | "eslint-plugin-unicorn": "^39.0.0", 77 | "ts-node": "^10.2.1", 78 | "typescript": "^4.0.5" 79 | }, 80 | "license": "MIT" 81 | } 82 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | // Create and export the debug instance so imported files can create extensions of it. 2 | // eslint-disable-next-line import/order 3 | import Debug from 'debug'; 4 | export const debug = Debug('evie'); 5 | 6 | import { CacheController, IDefaultExpireTimes, PublicESIService } from '@ionaru/esi-service'; 7 | import { EVE } from '@ionaru/eve-utils'; 8 | import * as Sentry from '@sentry/node'; 9 | import { HttpsAgent } from 'agentkeepalive'; 10 | import axios, { AxiosInstance } from 'axios'; 11 | import 'reflect-metadata'; // Required by TypeORM. 12 | import * as sourceMapSupport from 'source-map-support'; 13 | 14 | import { version } from '../package.json'; 15 | 16 | import { Application } from './controllers/application.controller'; 17 | 18 | export let esiService: PublicESIService; 19 | export let esiCache: CacheController; 20 | export let axiosInstance: AxiosInstance; 21 | 22 | const start = () => { 23 | sourceMapSupport.install(); 24 | 25 | debug(`Initializing Sentry (enabled: ${process.env.NODE_ENV === 'production'})`); 26 | Sentry.init({ 27 | debug: process.env.NODE_ENV !== 'production', 28 | dsn: 'https://4064eff091454347b283cc8b939a99a0@sentry.io/1318977', 29 | enabled: process.env.NODE_ENV === 'production', 30 | release: `evie-server@${version}`, 31 | }); 32 | 33 | debug('Creating axios instance'); 34 | axiosInstance = axios.create({ 35 | // keepAlive pools and reuses TCP connections, so it's faster 36 | httpsAgent: new HttpsAgent(), 37 | 38 | // cap the maximum content length we'll accept to 50MBs, just in case 39 | maxContentLength: 50 * 1000 * 1000, 40 | 41 | // follow up to 10 HTTP 3xx redirects 42 | maxRedirects: 10, 43 | 44 | // 60 sec timeout 45 | timeout: 60000, 46 | }); 47 | 48 | debug('Creating CacheController instance'); 49 | const defaultExpireTimes: IDefaultExpireTimes = {}; 50 | defaultExpireTimes[EVE.SDEURL] = 7200000; // 2 hours 51 | esiCache = new CacheController('data/responseCache.json', defaultExpireTimes, debug); 52 | 53 | debug('Creating PublicESIService instance'); 54 | esiService = new PublicESIService({ 55 | axiosInstance, 56 | cacheController: esiCache, 57 | debug, 58 | onRouteWarning: (route, text) => { 59 | Sentry.captureMessage(`${text}: ${route}`, Sentry.Severity.Warning); 60 | }, 61 | }); 62 | 63 | debug('Creating Application'); 64 | const application = new Application(); 65 | 66 | // Ensure application shuts down gracefully at all times. 67 | process.stdin.resume(); 68 | process.on('uncaughtException', (error: Error) => { 69 | Sentry.captureException(error); 70 | application.stop(error).then(); 71 | }); 72 | process.on('SIGINT', () => { 73 | debug('SIGINT received'); 74 | application.stop().then(); 75 | }); 76 | process.on('SIGTERM', () => { 77 | debug('SIGTERM received'); 78 | application.stop().then(); 79 | }); 80 | // Promises that fail should not cause the application to stop, instead we log the error. 81 | process.on('unhandledRejection', (reason, p): void => { 82 | Sentry.captureMessage(`Unhandled Rejection at: Promise ${p}, reason: ${reason}`, Sentry.Severity.Error); 83 | }); 84 | 85 | application.start().then().catch((error: Error) => application.stop(error)); 86 | }; 87 | 88 | if (require.main === module) { 89 | start(); 90 | } 91 | -------------------------------------------------------------------------------- /client/src/app/pages/industry/jobs/industry-jobs.component.html: -------------------------------------------------------------------------------- 1 |
2 |

You have no active industry jobs.

3 |
4 | 5 | 6 | 7 |
8 |

9 | 11 |

12 | bp 13 |

14 | 15 |

16 | bp 17 | 18 |
19 |

20 | 21 | {{ job.runs }} 22 | 23 | ( 24 | 25 | {{ job.probability * 100 | number:'1.0-0' }}% 26 | ) 27 | 28 |

29 | 30 |

31 | {{ blueprints[job.blueprint_id] && blueprints[job.blueprint_id]!.material_efficiency }}% 32 | 33 | {{ blueprints[job.blueprint_id] && blueprints[job.blueprint_id]!.material_efficiency + job.runs }}% 34 |

35 | 36 |

37 | {{ blueprints[job.blueprint_id] && blueprints[job.blueprint_id]!.time_efficiency }}% 38 | 39 | {{ blueprints[job.blueprint_id] && blueprints[job.blueprint_id]!.time_efficiency + job.runs * 2 }}% 40 |

41 |
42 | 43 |

44 | 45 | 46 | 47 | 48 | 49 | {{ job.percentageDone | number:'1.0-0' }}% 50 | 51 | 52 | 53 | {{ job.timeCountdown.value > 0 ? job.timeCountdown : 'Ready for delivery' }} 54 | 55 | 56 | {{ job.locationName }} ( {{job.locationType}} ) 57 | 58 |

59 |
60 | 61 | 62 |
63 |

Debug data

64 |

Industry data

65 |
{{ industryJobs | json }}
66 |
67 |

Blueprint data

68 |
{{ blueprints | json }}
69 |
70 | -------------------------------------------------------------------------------- /client/src/app/data-services/assets.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse, HttpHeaders, HttpResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { generateNumbersArray } from '@ionaru/array-utils'; 4 | import { EVE, ICharacterAssetsData, ICharacterAssetsLocationsData, ICharacterAssetsNamesData } from '@ionaru/eve-utils'; 5 | 6 | import { Character } from '../models/character/character.model'; 7 | import { Scope } from '../pages/scopes/scopes.component'; 8 | import { BaseService } from './base.service'; 9 | 10 | @Injectable() 11 | export class AssetsService extends BaseService { 12 | 13 | public async getAssets(character: Character): Promise { 14 | BaseService.confirmRequiredScope(character, Scope.ASSETS, 'getAssets'); 15 | 16 | const response = await this.getAssetsPage(character, 1); 17 | 18 | if (!response) { 19 | return []; 20 | } 21 | 22 | const assets = response.body || []; 23 | 24 | if (response.headers.has(BaseService.pagesHeaderName)) { 25 | const pages = Number(response.headers.get(BaseService.pagesHeaderName)); 26 | if (pages > 1) { 27 | const pageIterable = generateNumbersArray(pages, 2); 28 | 29 | await Promise.all(pageIterable.map(async (page) => { 30 | const pageResponse = await this.getAssetsPage(character, page); 31 | if (pageResponse && pageResponse.body) { 32 | assets.push(...pageResponse.body); 33 | } 34 | })); 35 | } 36 | } 37 | 38 | return assets; 39 | } 40 | 41 | public async getAssetsLocations(character: Character, items: number[]): Promise { 42 | BaseService.confirmRequiredScope(character, Scope.ASSETS, 'getAssets'); 43 | 44 | const url = EVE.getCharacterAssetsLocationsUrl(character.characterId); 45 | const headers = new HttpHeaders({Authorization: character.getAuthorizationHeader()}); 46 | const response = await this.http.post( 47 | url, 48 | items, 49 | {headers}, 50 | ).toPromise().catch(this.catchHandler); 51 | if (response instanceof HttpErrorResponse) { 52 | return []; 53 | } 54 | return response; 55 | } 56 | 57 | public async getAssetsNames(character: Character, items: number[]): Promise { 58 | BaseService.confirmRequiredScope(character, Scope.ASSETS, 'getAssets'); 59 | 60 | const url = EVE.getCharacterAssetsNamesUrl(character.characterId); 61 | const headers = new HttpHeaders({Authorization: character.getAuthorizationHeader()}); 62 | const response = await this.http.post( 63 | url, 64 | items, 65 | {headers}, 66 | ).toPromise().catch(this.catchHandler); 67 | if (response instanceof HttpErrorResponse) { 68 | return []; 69 | } 70 | return response; 71 | } 72 | 73 | private async getAssetsPage(character: Character, page: number) { 74 | const url = EVE.getCharacterAssetsUrl(character.characterId, page); 75 | const headers = new HttpHeaders({Authorization: character.getAuthorizationHeader()}); 76 | const response = await this.http.get( 77 | url, 78 | {headers, observe: 'response'}, 79 | ).toPromise>().catch(this.catchHandler); 80 | if (response instanceof HttpErrorResponse) { 81 | return; 82 | } 83 | 84 | return response; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /client/src/app/data-services/industry.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { IIndustrySystemsCostIndexActivity, IIndustrySystemsDataUnit } from '@ionaru/eve-utils'; 4 | 5 | import { BaseService, IServerResponse } from './base.service'; 6 | 7 | export interface IManufacturingData { 8 | blueprintId: number; 9 | materials: { 10 | id: number, 11 | quantity: number, 12 | }[]; 13 | skills: { 14 | id: number, 15 | level: number, 16 | }[]; 17 | time: number; 18 | result: { 19 | id: number, 20 | quantity: number, 21 | }; 22 | } 23 | 24 | export interface IRefiningProducts { 25 | id: number; 26 | quantity: number; 27 | } 28 | 29 | interface IManufacturingCache { 30 | [index: string]: string | undefined; 31 | } 32 | 33 | interface IRefiningCache { 34 | [index: string]: string; 35 | } 36 | 37 | @Injectable() 38 | export class IndustryService extends BaseService { 39 | 40 | private manufacturingCache: IManufacturingCache = {}; 41 | private refiningCache: IRefiningCache = {}; 42 | 43 | public async getManufacturingData(typeId: number): Promise { 44 | const url = `data/manufacturing/${typeId}`; 45 | 46 | if (url in this.manufacturingCache) { 47 | // tslint:disable-next-line:no-non-null-assertion 48 | return this.manufacturingCache[url] ? JSON.parse(this.manufacturingCache[url]!) as IManufacturingData : undefined; 49 | } 50 | 51 | const response = await this.http.get(url).toPromise>().catch(this.catchHandler); 52 | if (response instanceof HttpErrorResponse) { 53 | return; 54 | } 55 | 56 | const data = response ? response.data : undefined; 57 | this.manufacturingCache[url] = response ? JSON.stringify(response.data) : undefined; 58 | return data; 59 | } 60 | 61 | public async getRefiningProducts(typeId: number): Promise { 62 | const url = `data/refining/${typeId}`; 63 | 64 | if (url in this.refiningCache) { 65 | return JSON.parse(this.refiningCache[url]) as IRefiningProducts[]; 66 | } 67 | 68 | const response = await this.http.get(url).toPromise>().catch(this.catchHandler); 69 | if (response instanceof HttpErrorResponse) { 70 | return []; 71 | } 72 | 73 | const data = response && response.data ? response.data : []; 74 | this.refiningCache[url] = JSON.stringify(data); 75 | return data; 76 | } 77 | 78 | public async getSystem(systemId: number): Promise { 79 | const url = `data/industry/system/${systemId}`; 80 | 81 | const response = await this.http.get(url).toPromise>().catch(this.catchHandler); 82 | if (response instanceof HttpErrorResponse) { 83 | return; 84 | } 85 | 86 | return response.data; 87 | } 88 | 89 | public async getSystemCostIndex(systemId: number): Promise { 90 | const url = `data/industry/system/${systemId}`; 91 | 92 | const response = await this.http.get(url).toPromise>().catch(this.catchHandler); 93 | if (response instanceof HttpErrorResponse) { 94 | return; 95 | } 96 | 97 | const productionIndex = response.data.cost_indices.find( 98 | (index) => index.activity === IIndustrySystemsCostIndexActivity.MANUFACTURING, 99 | ); 100 | if (!productionIndex) { 101 | return; 102 | } 103 | 104 | return productionIndex.cost_index; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /client/src/app/pages/assets/assets.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from '@angular/core'; 2 | import { Title } from '@angular/platform-browser'; 3 | import { ICharacterAssetsData, ICharacterBlueprintsDataUnit } from '@ionaru/eve-utils'; 4 | 5 | import { Calc } from '../../../shared/calc.helper'; 6 | import { AssetsService } from '../../data-services/assets.service'; 7 | import { BlueprintsService } from '../../data-services/blueprints.service'; 8 | import { NamesService } from '../../data-services/names.service'; 9 | import { StructuresService } from '../../data-services/structures.service'; 10 | import { CharacterService } from '../../models/character/character.service'; 11 | import { DataPageComponent } from '../data-page/data-page.component'; 12 | import { Scope } from '../scopes/scopes.component'; 13 | import { createTitle } from '../../shared/title'; 14 | 15 | interface IExtendedCharacterBlueprintsDataUnit extends ICharacterBlueprintsDataUnit { 16 | location_name?: string; 17 | } 18 | 19 | @Component({ 20 | selector: 'app-assets', 21 | styleUrls: ['./assets.component.scss'], 22 | templateUrl: './assets.component.html', 23 | }) 24 | export class AssetsComponent extends DataPageComponent implements OnInit, OnDestroy { 25 | 26 | public blueprints?: IExtendedCharacterBlueprintsDataUnit[]; 27 | 28 | constructor( 29 | private assetsService: AssetsService, 30 | private blueprintsService: BlueprintsService, 31 | private title: Title, 32 | private namesService: NamesService, 33 | private structuresService: StructuresService, 34 | ) { 35 | super(); 36 | this.requiredScopes = [Scope.ASSETS, Scope.STRUCTURES]; 37 | } 38 | 39 | public ngOnInit() { 40 | super.ngOnInit(); 41 | this.title.setTitle(createTitle('Home')); 42 | this.getBlueprints().then(); 43 | } 44 | 45 | public async ngOnDestroy() { 46 | super.ngOnDestroy(); 47 | } 48 | 49 | public async getBlueprints() { 50 | if (CharacterService.selectedCharacter) { 51 | const character = CharacterService.selectedCharacter; 52 | 53 | const [blueprints, assets] = await Promise.all([ 54 | this.blueprintsService.getBlueprints(character), 55 | this.assetsService.getAssets(character), 56 | ]); 57 | 58 | const types = blueprints.map((blueprint) => blueprint.type_id); 59 | this.namesService.getNames(...types).then(); 60 | 61 | for (const blueprint of blueprints) { 62 | (blueprint as IExtendedCharacterBlueprintsDataUnit).location_name = await this.getBlueprintLocation(blueprint, assets); 63 | } 64 | 65 | this.blueprints = blueprints; 66 | } 67 | } 68 | 69 | public getName(id: number) { 70 | return NamesService.getNameFromData(id); 71 | } 72 | 73 | private async getBlueprintLocation(blueprint: ICharacterBlueprintsDataUnit, assets: ICharacterAssetsData): Promise { 74 | const container = assets.find((asset) => asset.item_id === blueprint.location_id); 75 | if (container) { 76 | // Blueprint is in a container. 77 | 78 | await this.namesService.getNames(container.location_id); 79 | return NamesService.getNameFromData(container.location_id); 80 | 81 | } else if (blueprint.location_flag === 'Hangar') { 82 | // Blueprint is in a hangar somewhere. 83 | 84 | if (blueprint.location_id > Calc.maxIntegerValue && CharacterService.selectedCharacter) { 85 | const character = CharacterService.selectedCharacter; 86 | const structureInfo = await this.structuresService.getStructureInfo(character, blueprint.location_id); 87 | return structureInfo ? structureInfo.name : 'Unknown structure'; 88 | } 89 | 90 | await this.namesService.getNames(blueprint.location_id); 91 | return NamesService.getNameFromData(blueprint.location_id); 92 | } 93 | 94 | return 'Unknown location'; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /server/src/loggers/request.logger.ts: -------------------------------------------------------------------------------- 1 | import * as chalk from 'chalk'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | import * as onFinished from 'on-finished'; 4 | 5 | import { debug } from '../index'; 6 | import { IResponse } from '../routers/base.router'; 7 | 8 | export class RequestLogger { 9 | public static ignoredUrls = ['/modules', '/images', '/fonts', '/stylesheets', '/scripts', '/favicon.ico']; 10 | public static ignoredExtension = ['.ico', '.js', '.css', '.png', '.jpg', '.svg', '.html']; 11 | public static arrow = chalk.white('->'); 12 | private static debug = debug.extend('request'); 13 | 14 | public static logRequest(): any { 15 | // eslint-disable-next-line sonarjs/cognitive-complexity 16 | return (request: Request, response: Response, next: NextFunction) => { 17 | 18 | const requestStartTime = Date.now(); 19 | 20 | // Runs when the request has finished. 21 | onFinished(response, async (_err, endResponse: IResponse) => { 22 | 23 | const ignoredUrlMatch = RequestLogger.ignoredUrls.some( 24 | (ignoredUrl) => request.originalUrl.startsWith(ignoredUrl)); 25 | 26 | const ignoredExtensionMatch = RequestLogger.ignoredExtension.some( 27 | (ignoredExtension) => request.originalUrl.endsWith(ignoredExtension)); 28 | 29 | // Do not log requests to static URLs and files unless their status code is not OK. 30 | if ((!ignoredExtensionMatch && !ignoredUrlMatch) || (endResponse.statusCode !== 200 && endResponse.statusCode !== 304)) { 31 | 32 | const message = endResponse.data ? endResponse.data.message : undefined; 33 | 34 | const statusColor = RequestLogger.getStatusColor(endResponse.statusCode); 35 | const status = statusColor(`${endResponse.statusCode} ${endResponse.statusMessage}`); 36 | 37 | const route = endResponse.route; 38 | const router = chalk.white(route && route.length ? route.join(' > ') : 'ServeStatic'); 39 | 40 | const ip = RequestLogger.getIp(request); 41 | const identifier = `${ip} (${chalk.white(request.sessionID!)})`; 42 | 43 | const text = `${request.method} ${request.originalUrl}`; 44 | 45 | const requestDuration = Date.now() - requestStartTime; 46 | const arrow = RequestLogger.arrow; 47 | 48 | let logContent = `${identifier}: ${text} ${arrow} ${router} ${arrow} `; 49 | if (message) { 50 | logContent += `${message} `; 51 | } 52 | logContent += `${status}, ${requestDuration}ms`; 53 | 54 | if (endResponse.statusCode >= 500) { 55 | process.stderr.write(logContent + '\n'); 56 | } else if (endResponse.statusCode >= 400) { 57 | process.emitWarning(logContent); 58 | } else { 59 | RequestLogger.debug(logContent); 60 | } 61 | } 62 | }); 63 | next(); 64 | }; 65 | } 66 | 67 | public static getStatusColor(statusCode: number): chalk.Chalk { 68 | if (statusCode >= 500) { 69 | return chalk.red; 70 | } else if (statusCode >= 400) { 71 | return chalk.yellow; 72 | } else if (statusCode >= 300) { 73 | return chalk.cyan; 74 | } else if (statusCode >= 200) { 75 | return chalk.green; 76 | } else { 77 | return chalk.whiteBright; 78 | } 79 | } 80 | 81 | private static getIp(request: Request) { 82 | 83 | const ip = request.headers['x-forwarded-for'] || 84 | request.connection.remoteAddress || 85 | request.socket.remoteAddress || 86 | request.ip || 87 | 'Unknown IP'; 88 | 89 | // make IPv6 readable. 90 | return (typeof ip === 'string' && ip.substr(0, 7) === '::ffff:') ? ip.substr(7) : ip; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /client/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | EVIE 6 | 7 | 8 | 9 | 10 | 11 | 68 | 69 | 70 | 71 | 72 |
73 | 74 | 109 | 110 |
111 |
112 |

EVIE

113 | 114 |
115 |
116 |
117 | 118 | 119 | -------------------------------------------------------------------------------- /client/src/app/models/character/character.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpErrorResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { EVE, ICharacterData } from '@ionaru/eve-utils'; 4 | import { Subject } from 'rxjs'; 5 | 6 | import { Calc } from '../../../shared/calc.helper'; 7 | import { IServerResponse } from '../../data-services/base.service'; 8 | import { Character, IApiCharacterData, ITokenRefreshResponse } from './character.model'; 9 | 10 | const tokenRefreshInterval = 15 * Calc.minute; 11 | 12 | @Injectable() 13 | export class CharacterService { 14 | 15 | public static readonly characterChangeEvent = new Subject(); 16 | 17 | private static _selectedCharacter?: Character; 18 | public static get selectedCharacter(): Character | undefined { return this._selectedCharacter; } 19 | 20 | constructor(private http: HttpClient) { } 21 | 22 | public async getPublicCharacterData(character: Character): Promise { 23 | const url = EVE.getCharacterUrl(character.characterId); 24 | const response = await this.http.get(url).toPromise(); 25 | character.birthday = new Date(response.birthday); 26 | character.gender = response.gender; 27 | character.corporationId = response.corporation_id || 1; 28 | character.allianceId = response.alliance_id; 29 | character.description = response.description; 30 | character.securityStatus = response.security_status; 31 | } 32 | 33 | public async registerCharacter(data: IApiCharacterData): Promise { 34 | 35 | const character = new Character(data); 36 | this.getPublicCharacterData(character).then(); 37 | if (data.isActive) { 38 | this.setActiveCharacter(character, true).then(); 39 | } 40 | 41 | const tokenExpiryTime = character.tokenExpiry.getTime(); 42 | const currentTime = Date.now(); 43 | 44 | const tokenExpiry = tokenExpiryTime - currentTime; 45 | if (tokenExpiry < -(3 * Calc.week)) { 46 | // Refresh token is too old. 47 | character.invalidateAuth(); 48 | return character; 49 | } 50 | 51 | const timeLeft = tokenExpiry - tokenRefreshInterval; 52 | if (timeLeft <= 0) { 53 | await this.refreshToken(character); 54 | } 55 | 56 | if (character.hasValidAuth) { 57 | character.refreshTimer = window.setInterval(() => { 58 | this.refreshToken(character).then(); 59 | }, tokenRefreshInterval); 60 | } 61 | 62 | return character; 63 | } 64 | 65 | public async refreshToken(character: Character): Promise { 66 | const uuid = character.uuid; 67 | const url = `/sso/refresh?uuid=${uuid}`; 68 | const response = await this.http.get(url).toPromise>() 69 | .catch((e: HttpErrorResponse) => e); 70 | if (response instanceof HttpErrorResponse) { 71 | character.invalidateAuth(); 72 | return; 73 | } 74 | character.accessToken = response.data.token; 75 | } 76 | 77 | public async setActiveCharacter(character?: Character, skipServerCall = false): Promise { 78 | 79 | if (!skipServerCall) { 80 | const url = '/sso/activate'; 81 | const characterUUID = character ? character.uuid : undefined; 82 | this.http.post(url, {characterUUID}).toPromise().then(); 83 | } 84 | 85 | CharacterService._selectedCharacter = character; 86 | CharacterService.characterChangeEvent.next(character); 87 | } 88 | 89 | public async deleteCharacter(character: Character): Promise { 90 | 91 | const url = '/sso/delete'; 92 | const characterUUID = character.uuid; 93 | 94 | const response = await this.http.post(url, {characterUUID}).toPromise() 95 | .catch((e: HttpErrorResponse) => e); 96 | if (response instanceof HttpErrorResponse) { 97 | throw response.error; 98 | } 99 | 100 | if (response.state === 'success') { 101 | character.invalidateAuth(); 102 | 103 | if (CharacterService.selectedCharacter && CharacterService.selectedCharacter.uuid === character.uuid) { 104 | this.setActiveCharacter().then(); 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /client/src/app/models/character/character.model.ts: -------------------------------------------------------------------------------- 1 | import { ICharacterSkillQueueDataUnit } from '@ionaru/eve-utils'; 2 | import jwtDecode, { JwtPayload } from 'jwt-decode'; 3 | 4 | import { Calc } from '../../../shared/calc.helper'; 5 | 6 | export class Character { 7 | public characterId: number; 8 | public uuid: string; 9 | public name: string; 10 | public _accessToken?: string; 11 | public scopes: string[]; 12 | public tokenExpiry: Date; 13 | public ownerHash: string; 14 | public gender?: string; 15 | public corporationId?: number; 16 | public birthday?: Date; 17 | public securityStatus?: number; 18 | public description?: string; 19 | public corporation?: string; 20 | public allianceId?: number; 21 | public alliance?: string; 22 | public race?: string; 23 | public bloodline?: string; 24 | public ancestory?: string; 25 | public balance = 0; 26 | public walletJournal: object[] = []; 27 | public walletTransactions: object[] = []; 28 | public currentTrainingSkill?: ISkillQueueDataWithName; 29 | public currentTrainingFinish?: Date; 30 | public currentTrainingCountdown?: number | countdown.Timespan; 31 | 32 | public totalTrainingFinish?: Date; 33 | public totalTrainingCountdown?: number | countdown.Timespan; 34 | 35 | public skillQueue: number[] = []; 36 | public assets: object[] = []; 37 | public planets: object[] = []; 38 | public mails: object[] = []; 39 | public location: { 40 | id?: number; 41 | name?: string | null; 42 | } = {}; 43 | public currentShip: { 44 | id?: number; 45 | name?: string; 46 | type?: string | null; 47 | } = {}; 48 | public refreshTimer?: number; 49 | 50 | public constructor(data: IApiCharacterData) { 51 | this.accessToken = data.accessToken; 52 | this.uuid = data.uuid; 53 | 54 | // Decode access token for information 55 | const tokenData = jwtDecode(data.accessToken); 56 | this.tokenExpiry = new Date(Calc.secondsToMilliseconds(tokenData.exp)); 57 | this.scopes = (typeof tokenData.scp === 'string' ? [tokenData.scp] : tokenData.scp) || []; 58 | this.ownerHash = tokenData.owner; 59 | this.name = tokenData.name; 60 | this.characterId = Number(tokenData.sub.split(':')[2]); 61 | } 62 | 63 | public set accessToken(token: string | undefined) { 64 | if (!token) { 65 | return; 66 | } 67 | 68 | const tokenData = jwtDecode(token); 69 | this._accessToken = token; 70 | this.tokenExpiry = new Date(Calc.secondsToMilliseconds(tokenData.exp)); 71 | } 72 | 73 | public get accessToken(): string | undefined { 74 | return this._accessToken; 75 | } 76 | 77 | public updateAuth(data: IApiCharacterData): void { 78 | this.characterId = data.characterId; 79 | this.name = data.name; 80 | this.accessToken = data.accessToken; 81 | this.ownerHash = data.ownerHash; 82 | this.uuid = data.uuid; 83 | this.scopes = data.scopes.split(' '); 84 | this.tokenExpiry = new Date(data.tokenExpiry); 85 | } 86 | 87 | public get hasValidAuth() { 88 | return this.accessToken && this.tokenExpiry >= new Date(); 89 | } 90 | 91 | public hasScope(...scopes: string[]) { 92 | return this.hasValidAuth && scopes.every((scope) => this.scopes.includes(scope)); 93 | } 94 | 95 | public getAuthorizationHeader() { 96 | return 'Bearer ' + this.accessToken; 97 | } 98 | 99 | public invalidateAuth() { 100 | this.accessToken = undefined; 101 | window.clearInterval(this.refreshTimer); 102 | } 103 | } 104 | 105 | export interface IApiCharacterData { 106 | accessToken: string; 107 | characterId: number; 108 | name: string; 109 | ownerHash: string; 110 | uuid: string; 111 | scopes: string; 112 | tokenExpiry: string; 113 | isActive: boolean; 114 | } 115 | 116 | export interface ITokenRefreshResponse { 117 | token: string; 118 | } 119 | 120 | export interface ISkillQueueDataWithName extends ICharacterSkillQueueDataUnit { 121 | name?: string; 122 | } 123 | 124 | interface IJWTToken extends JwtPayload { 125 | azp: string; 126 | exp: number; 127 | iss: string; 128 | jti: string; 129 | kid: string; 130 | name: string; 131 | owner: string; 132 | scp: string[] | string; 133 | sub: string; 134 | } 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EVIE 2 | 3 | [![](https://img.shields.io/badge/fly_safe-o7-2F849E.svg?style=for-the-badge)](https://www.eveonline.com/) 4 | 5 | EVIE is a web-based API interface for EVE Online. It’s built using modern web technologies to provide a fast and responsive interface while still displaying a large amount of information. 6 | 7 | My goal of this project is to build a robust API interface that is usable on any platform: including both desktop and mobile. *cough* cross-platform app *cough*. I don’t just want to display data from the API, but also do calculations, predictions and more to make EVIE more useful, even when you also have the game open. 8 | 9 | Right now the features are limited, but new pages are being built and I have a lot of exciting features planned! 10 | 11 | --- 12 | 13 | ### Screenshots 14 | **Dashboard** 15 | 16 | ![](https://data.saturnserver.org/images/dashboard.png) 17 | 18 | **Skills Page** 19 | 20 | ![](https://data.saturnserver.org/images/skills.png) 21 | 22 | **Wallet Page** 23 | 24 | ![](https://data.saturnserver.org/images/wallet.png) 25 | 26 | **Ore prices table** 27 | 28 | ![](https://data.saturnserver.org/images/ores.png) 29 | 30 | --- 31 | 32 | ### TODO & Ideas 33 | 34 | #### General 35 | * Option to switch between TQ and SiSi. 36 | * ~~Use shared functions between client and server~~ 37 | * ~~Selecting a subset of scopes for SSO~~ 38 | * Something special on character birthday / April fools / christmas 39 | * Responsiveness 40 | * More pages! 41 | * More tests! 42 | * Set up automated testing. 43 | 44 | #### Wallet page 45 | * Pagination 46 | * Cash-flow breakdown 47 | 48 | #### Industry page 49 | * ~~Fetch industry information from SDE(-like API) to server~~ 50 | * ~~Create routes so client can fetch industry info in small portions~~ 51 | * Do cost calculations on resources, recursively 52 | 53 | --- 54 | 55 | ### Configuration 56 | EVIE requires some set-up to work, this information is for developers. 57 | 58 | #### EVE Online developer applications 59 | 60 | EVIE works with two EVE Online developer applications, one for logging in, and one for character auth. 61 | This is done so users can decide what scopes to grant for each of their characters. 62 | 63 | - SSO Login (Authentication Only) 64 | - SSO Auth (Authentication & API Access) 65 | - Give this all available scopes, only a subset will be used for EVIE. 66 | 67 | #### Environment variables 68 | - `DEBUG`: Parameters for the debug package. See for more information. 69 | - `EVIE_CLIENT_PORT`: The port the client should run on. 70 | - `EVIE_DATA_VOLUME`: Docker volume for the data folder.` 71 | - `EVIE_DB_HOST`: Host of the database to connect to. 72 | - `EVIE_DB_NAME`: Name of the database to connect to. 73 | - `EVIE_DB_PASS`: Password to use in the database connection. 74 | - `EVIE_DB_PORT`: Port of the database to connect to. 75 | - `EVIE_DB_SSL_CA` (optional): Location of the CA certificate **in the container** to use for a secure database connection. 76 | - `EVIE_DB_SSL_CERT` (optional): Location of the client certificate **in the container** to use for a secure database connection. 77 | - `EVIE_DB_SSL_KEY` (optional): Location of the client key **in the container** to use for a secure database connection. 78 | - `EVIE_DB_SSL_REJECT` (boolean): Whether to reject an insecure connection to the database. 79 | - `EVIE_DB_USER`: Username to use in the database connection. 80 | - `EVIE_ENV`: Configuration to pass to Angular for building. 81 | - `EVIE_FA_TOKEN`: FontAwesome 5 token. 82 | - `EVIE_PROXY_SETTING`: The setting Express' trust proxy should be set to. (Default: 1) 83 | - `EVIE_SERVER_PORT`: The port the server should run on. 84 | - `EVIE_SESSION_KEY`: Name of the session ID cookie. 85 | - `EVIE_SESSION_SECRET`: Secret used to sign the session ID cookie. 86 | - `EVIE_SESSION_SECURE` (boolean): Serve cookies over a secure connection only? Disable for local development. 87 | - `EVIE_SSO_AUTH_CALLBACK`: Callback URL of the SSO application that handles character auth. 88 | - `EVIE_SSO_AUTH_CLIENT`: Client ID of the SSO application that handles character auth. 89 | - `EVIE_SSO_AUTH_SECRET`: Secret Key of the SSO application that handles character auth. 90 | - `EVIE_SSO_LOGIN_CALLBACK`: Callback URL of the SSO application that handles login. 91 | - `EVIE_SSO_LOGIN_CLIENT`: Client ID of the SSO application that handles login. 92 | - `EVIE_SSO_LOGIN_SECRET`: Secret Key of the SSO application that handles login. 93 | - `EVIE_SSO_APP_CALLBACK`: Callback URL of the SSO application that handles login for the app. 94 | - `EVIE_SSO_APP_SECRET`: Client ID of the SSO application that handles login for the app. 95 | - `EVIE_SSO_APP_SECRET`: Secret Key of the SSO application that handles auth for the app. 96 | -------------------------------------------------------------------------------- /client/src/app/navigation/navigation.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../variables'; 2 | 3 | nav { 4 | background-color: $body-secondary; 5 | user-select: none; 6 | } 7 | 8 | .navbar.fixed-top { 9 | box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.25); 10 | 11 | .navbar-toggler { 12 | color: $primary; 13 | } 14 | 15 | .navbar-nav { 16 | .nav-item { 17 | text-align: right; 18 | 19 | .nav-link { 20 | position: relative; 21 | color: #ffffff; 22 | 23 | &::before { 24 | background-color: #ffffff; 25 | bottom: 0; 26 | content: ''; 27 | height: 2px; 28 | left: 0; 29 | position: absolute; 30 | right: 0; 31 | transform: scaleX(0); 32 | transition: transform 0.2s ease-in-out, visibility 0.2s ease-in-out; 33 | visibility: hidden; 34 | } 35 | 36 | &:hover { 37 | &::before { 38 | transform: scaleX(0.8); 39 | visibility: visible; 40 | } 41 | } 42 | 43 | &.active { 44 | &::before { 45 | transform: scaleX(1); 46 | visibility: visible; 47 | } 48 | } 49 | 50 | &.logout { 51 | padding: 0; 52 | 53 | img { 54 | filter: grayscale(100%) brightness(200%); 55 | height: 37px; 56 | } 57 | } 58 | 59 | .nav-link-text { 60 | font-size: 16px; 61 | } 62 | } 63 | 64 | &.dropdown { 65 | .dropdown-menu { 66 | .dropdown-item { 67 | img { 68 | margin-left: -4px; 69 | margin-bottom: 2px; 70 | max-height: 18px; 71 | transform: scale(1.4); 72 | filter: grayscale(100%) brightness(200%); 73 | } 74 | } 75 | } 76 | } 77 | } 78 | } 79 | } 80 | 81 | .navbar.fixed-left { 82 | bottom: 0; 83 | box-shadow: 2px 0 5px 0 rgba(0, 0, 0, 0.25); 84 | left: 0; 85 | padding: 0; 86 | position: fixed; 87 | text-align: center; 88 | top: $navbar-top-height; 89 | width: $navbar-side-width; 90 | z-index: 1029; 91 | flex-direction: column; 92 | justify-content: flex-start; 93 | 94 | .scrollContainer { 95 | overflow-y: auto; 96 | overflow-x: hidden; 97 | padding-bottom: 50px; 98 | } 99 | 100 | .character-item { 101 | cursor: pointer; 102 | 103 | img { 104 | height: 50px; 105 | width: 50px; 106 | } 107 | } 108 | 109 | .navbar-item { 110 | cursor: pointer; 111 | display: block; 112 | padding-bottom: 5px; 113 | padding-top: 5px; 114 | position: relative; 115 | text-decoration: none; 116 | 117 | &:hover { 118 | &::after { 119 | transform: scaleY(0.7); 120 | visibility: visible; 121 | } 122 | } 123 | 124 | &.active { 125 | &::after { 126 | transform: scaleY(0.9); 127 | visibility: visible; 128 | } 129 | } 130 | 131 | &::after { 132 | background-color: #ffffff; 133 | bottom: 0; 134 | content: ''; 135 | position: absolute; 136 | right: 4px; 137 | top: 0; 138 | transform: scaleY(0); 139 | transition: all 0.2s ease-in-out; 140 | visibility: hidden; 141 | width: 2px; 142 | } 143 | 144 | img { 145 | filter: brightness(0) invert(100%); 146 | max-width: 85%; 147 | } 148 | 149 | &.time { 150 | bottom: 0; 151 | margin-bottom: 0; 152 | padding-bottom: 10px; 153 | padding-top: 10px; 154 | pointer-events: none; 155 | position: absolute; 156 | width: 100%; 157 | 158 | &.disabled { 159 | opacity: 1; 160 | } 161 | } 162 | 163 | &.disabled { 164 | opacity: 0.25; 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /server/migrations/1573054086025-Initial.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | interface IBaseData { 4 | id: number; 5 | uuid: string; 6 | createdOn: Date; 7 | updatedOn: Date; 8 | } 9 | 10 | interface IUserData extends IBaseData { 11 | email: string; 12 | isAdmin: string; 13 | timesLogin: number; 14 | lastLogin: Date; 15 | } 16 | 17 | interface ICharacterData extends IBaseData { 18 | name: string; 19 | characterId: number; 20 | accessToken: string; 21 | tokenExpiry: Date; 22 | refreshToken: string; 23 | scopes: string; 24 | ownerHash: string; 25 | isActive: number; 26 | userId: number; 27 | } 28 | 29 | // noinspection JSUnusedGlobalSymbols 30 | export class Initial1573054086025 implements MigrationInterface { 31 | 32 | private static convertDate(date: Date): string { 33 | const seconds = Math.floor(date.getTime() / 1000); 34 | return `from_unixtime(${seconds})`; 35 | } 36 | 37 | private static async migrateCharacterTable(queryRunner: QueryRunner) { 38 | let characterData: ICharacterData[] = []; 39 | const characterTableExists = await queryRunner.query(`SHOW TABLES LIKE 'character'`); 40 | if (characterTableExists.length) { 41 | characterData = await queryRunner.query('SELECT * FROM `character`'); 42 | } 43 | 44 | // Drop old table 45 | await queryRunner.query('DROP TABLE IF EXISTS `character`'); 46 | 47 | await queryRunner.query('CREATE TABLE `character` (`id` int NOT NULL AUTO_INCREMENT, `uuid` varchar(36) NOT NULL, `createdOn` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updatedOn` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `name` varchar(255) NULL, `characterId` int NULL, `accessToken` text NULL, `tokenExpiry` datetime NULL, `refreshToken` varchar(255) NULL, `scopes` text NULL, `ownerHash` varchar(255) NULL, `isActive` tinyint NOT NULL DEFAULT 0, `userId` int NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB', undefined); 48 | 49 | if (characterData.length) { 50 | const characterValues = characterData.map((data) => `(${data.id},'${data.uuid}',${Initial1573054086025.convertDate(data.createdOn)},${Initial1573054086025.convertDate(data.updatedOn)},'${data.name}',${data.characterId},'${data.accessToken}',${Initial1573054086025.convertDate(data.tokenExpiry)},'${data.refreshToken}','${data.scopes}','${data.ownerHash}',${data.isActive},${data.userId})`); 51 | await queryRunner.query(`INSERT INTO \`character\` (id, uuid, createdOn, updatedOn, name, characterId, accessToken, tokenExpiry, refreshToken, scopes, ownerHash, isActive, userId) VALUES ${characterValues.join(',')}`); 52 | } 53 | } 54 | 55 | private static async migrateUserTable(queryRunner: QueryRunner) { 56 | let userData: IUserData[] = []; 57 | const userTableExists = await queryRunner.query(`SHOW TABLES LIKE 'user'`); 58 | if (userTableExists.length) { 59 | userData = await queryRunner.query('SELECT * FROM `user`'); 60 | } 61 | 62 | // Drop old table 63 | await queryRunner.query('DROP TABLE IF EXISTS `user`'); 64 | 65 | await queryRunner.query('CREATE TABLE `user` (`id` int NOT NULL AUTO_INCREMENT, `uuid` varchar(36) NOT NULL, `createdOn` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updatedOn` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `email` varchar(255) NULL, `isAdmin` tinyint NOT NULL DEFAULT 0, `timesLogin` int NOT NULL DEFAULT 1, `lastLogin` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`)) ENGINE=InnoDB', undefined); 66 | 67 | if (userData.length) { 68 | const userValues = userData.map((data) => `(${data.id},'${data.uuid}',${Initial1573054086025.convertDate(data.createdOn)},${Initial1573054086025.convertDate(data.updatedOn)},'${data.email}',${data.isAdmin},${data.timesLogin},${Initial1573054086025.convertDate(data.lastLogin)})`); 69 | await queryRunner.query(`INSERT INTO \`user\` (id, uuid, createdOn, updatedOn, email, isAdmin, timesLogin, lastLogin) VALUES ${userValues.join(',')}`); 70 | } 71 | } 72 | 73 | public async up(queryRunner: QueryRunner): Promise { 74 | await Initial1573054086025.migrateCharacterTable(queryRunner); 75 | await Initial1573054086025.migrateUserTable(queryRunner); 76 | await queryRunner.query('ALTER TABLE `character` ADD CONSTRAINT `FK_04c2fb52adfa5265763de8c4464` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION', undefined); 77 | } 78 | 79 | public async down(queryRunner: QueryRunner): Promise { 80 | await queryRunner.query('ALTER TABLE `character` DROP FOREIGN KEY `FK_04c2fb52adfa5265763de8c4464`', undefined); 81 | await queryRunner.query('DROP TABLE `character`', undefined); 82 | await queryRunner.query('DROP TABLE `user`', undefined); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /client/src/app/pages/wallet/wallet.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Title } from '@angular/platform-browser'; 3 | import { ICharacterWalletJournalData, ICharacterWalletJournalDataUnit } from '@ionaru/eve-utils'; 4 | 5 | import { ITableHeader } from '../../components/sor-table/sor-table.component'; 6 | import { WalletJournalService } from '../../data-services/wallet-journal.service'; 7 | import { WalletService } from '../../data-services/wallet.service'; 8 | import { CharacterService } from '../../models/character/character.service'; 9 | import { CountUp } from '../../shared/count-up'; 10 | import { DataPageComponent } from '../data-page/data-page.component'; 11 | import { Scope } from '../scopes/scopes.component'; 12 | import { createTitle } from '../../shared/title'; 13 | 14 | @Component({ 15 | selector: 'app-wallet', 16 | styleUrls: ['./wallet.component.scss'], 17 | templateUrl: './wallet.component.html', 18 | }) 19 | export class WalletComponent extends DataPageComponent implements OnInit { 20 | 21 | public journalData: ICharacterWalletJournalData = []; 22 | public balanceCountUp?: CountUp; 23 | 24 | public taxAmount = 0; 25 | 26 | public tableSettings: ITableHeader[] = [{ 27 | attribute: 'date', 28 | hint: 'In EVE-Time', 29 | pipe: 'date', 30 | sort: true, 31 | title: 'Timestamp', 32 | sortAttribute: 'id', 33 | }, { 34 | attribute: 'amount', 35 | classFunction: (data) => this.getAmountClass(data.amount), 36 | pipe: 'number', 37 | pipeVar: '0.2-2', 38 | suffix: ' ISK', 39 | }, { 40 | attribute: 'balance', 41 | pipe: 'number', 42 | pipeVar: '0.2-2', 43 | suffix: ' ISK', 44 | }, { 45 | attribute: 'description', 46 | }]; 47 | 48 | private balance!: number; 49 | 50 | constructor( 51 | private walletService: WalletService, 52 | private journalService: WalletJournalService, 53 | private title: Title, 54 | ) { 55 | super(); 56 | this.requiredScopes = [Scope.WALLET]; 57 | } 58 | 59 | public ngOnInit() { 60 | super.ngOnInit(); 61 | this.title.setTitle(createTitle('Wallet')); 62 | this.balanceCountUp = undefined; 63 | if (WalletComponent.hasWalletScope) { 64 | this.getBalanceData().then(); 65 | this.getJournalData().then(); 66 | } 67 | } 68 | 69 | public getAmountClass(amount?: number) { 70 | if (amount && amount < 0) { 71 | return 'negative'; 72 | } 73 | 74 | if (amount && amount > 0) { 75 | return 'positive'; 76 | } 77 | 78 | return ''; 79 | } 80 | 81 | public static get hasWalletScope() { 82 | return CharacterService.selectedCharacter && CharacterService.selectedCharacter.hasScope(Scope.WALLET); 83 | } 84 | 85 | public async getBalanceData() { 86 | if (CharacterService.selectedCharacter) { 87 | this.balance = await this.walletService.getWalletBalance(CharacterService.selectedCharacter); 88 | if (!this.balanceCountUp) { 89 | this.balanceCountUp = new CountUp('wallet-balance', 0, 0, 2); 90 | } 91 | this.balanceCountUp.update(this.balance); 92 | } 93 | } 94 | 95 | public async getJournalData() { 96 | if (CharacterService.selectedCharacter) { 97 | const journalData = await this.journalService.getWalletJournal(CharacterService.selectedCharacter); 98 | 99 | this.calculateTaxPaid(journalData); 100 | 101 | const journalDataWithTax: ICharacterWalletJournalData = []; 102 | 103 | for (const journalEntry of journalData) { 104 | if (journalEntry.tax) { 105 | journalEntry.balance = (journalEntry.balance || 0) + journalEntry.tax; 106 | journalEntry.amount = (journalEntry.amount || 0) + journalEntry.tax; 107 | journalDataWithTax.push({ 108 | date: journalEntry.date, 109 | // Make sure this always appears after the initial transaction. 110 | id: journalEntry.id + 0.5, 111 | description: 'Corporation tax', 112 | ref_type: 'corporate_reward_tax', 113 | amount: -journalEntry.tax, 114 | balance: (journalEntry.balance || 0) - journalEntry.tax, 115 | }); 116 | } 117 | journalDataWithTax.push(journalEntry); 118 | } 119 | 120 | this.journalData = journalDataWithTax; 121 | } 122 | } 123 | 124 | public calculateTaxPaid(journalData: ICharacterWalletJournalData) { 125 | const reducer = (previous: number, current: ICharacterWalletJournalDataUnit) => previous + (current.tax || 0); 126 | this.taxAmount = journalData.reduce(reducer, 0); 127 | } 128 | } 129 | --------------------------------------------------------------------------------