├── frontend ├── .gitattributes ├── src │ ├── components │ │ ├── vars.scss │ │ ├── api │ │ │ ├── TokensList.vue │ │ │ ├── TokenForm.vue │ │ │ └── Token.vue │ │ ├── devices │ │ │ ├── DevicesList.vue │ │ │ └── DeviceForm.vue │ │ ├── filter │ │ │ └── ToggleButton.vue │ │ ├── stats │ │ │ └── MetricsForm.vue │ │ ├── TimelineOptions.vue │ │ ├── Messages.vue │ │ ├── MapCircle.vue │ │ └── Header.vue │ ├── styles │ │ ├── variables.scss │ │ ├── common.scss │ │ ├── animations.scss │ │ ├── layout.scss │ │ ├── elements.scss │ │ └── views.scss │ ├── models │ │ ├── StatsRequest.ts │ │ ├── LocationStats.ts │ │ ├── Types.ts │ │ ├── AuthInfo.ts │ │ ├── AutocompleteValue.ts │ │ ├── UserDevice.ts │ │ ├── User.ts │ │ ├── Message.ts │ │ ├── UserSession.ts │ │ ├── Country.ts │ │ ├── TimelineMetricsConfiguration.ts │ │ ├── GPSPoint.ts │ │ └── LocationQuery.ts │ ├── mixins │ │ ├── Notifications.vue │ │ ├── Units.vue │ │ ├── api │ │ │ ├── Users.vue │ │ │ ├── Stats.vue │ │ │ ├── Auth.vue │ │ │ ├── Sessions.vue │ │ │ ├── GPSData.vue │ │ │ ├── Devices.vue │ │ │ └── Common.vue │ │ ├── Clipboard.vue │ │ ├── Text.vue │ │ ├── Api.vue │ │ ├── Dropdowns.vue │ │ ├── MapView.vue │ │ ├── Geo.vue │ │ ├── LocationQuery.vue │ │ ├── Paginate.vue │ │ ├── Dates.vue │ │ ├── Stats.vue │ │ ├── Routes.vue │ │ └── URLQueryHandler.vue │ ├── assets │ │ ├── logo.svg │ │ ├── main.css │ │ └── base.css │ ├── views │ │ ├── Logout.vue │ │ ├── HomeView.vue │ │ ├── API.vue │ │ ├── Devices.vue │ │ └── Login.vue │ ├── elements │ │ ├── Loading.vue │ │ ├── DropdownItem.vue │ │ ├── ConfirmDialog.vue │ │ ├── FloatingButton.vue │ │ ├── PopupMessage.vue │ │ ├── Dropdown.vue │ │ ├── Modal.vue │ │ ├── CountrySelector.vue │ │ └── Autocomplete.vue │ ├── main.ts │ ├── router │ │ └── index.ts │ └── App.vue ├── Makefile ├── env.d.ts ├── public │ ├── favicon.ico │ └── icons │ │ ├── pwa-192x192.png │ │ ├── pwa-512x512.png │ │ ├── pwa-maskable-192x192.png │ │ └── pwa-maskable-512x512.png ├── .prettierrc.json ├── tsconfig.json ├── .editorconfig ├── tsconfig.app.json ├── index.html ├── tsconfig.node.json ├── README.md ├── eslint.config.ts ├── package.json └── vite.config.ts ├── src ├── db │ ├── index.ts │ ├── types │ │ ├── Role.ts │ │ ├── UserRole.ts │ │ ├── UserDevice.ts │ │ ├── UserSession.ts │ │ ├── User.ts │ │ └── GPSData.ts │ ├── Migrations.ts │ └── migrations │ │ └── 001_add_metadata_to_location_points.ts ├── main.ts ├── responses │ ├── index.ts │ └── LocationStats.ts ├── models │ ├── RoleName.ts │ ├── index.ts │ ├── UserDevice.ts │ ├── GPSPoint.ts │ ├── Role.ts │ ├── UserSession.ts │ └── User.ts ├── helpers │ ├── logging.ts │ ├── security.ts │ ├── random.ts │ └── cookies.ts ├── requests │ ├── index.ts │ └── StatsRequest.ts ├── routes │ ├── api │ │ ├── v1 │ │ │ ├── Route.ts │ │ │ ├── index.ts │ │ │ ├── UserSelf.ts │ │ │ ├── Stats.ts │ │ │ ├── TokensById.ts │ │ │ ├── LocationInfo.ts │ │ │ ├── Devices.ts │ │ │ ├── Tokens.ts │ │ │ ├── Auth.ts │ │ │ ├── DevicesById.ts │ │ │ └── GPSData.ts │ │ ├── index.ts │ │ └── Route.ts │ ├── Routes.ts │ ├── index.ts │ ├── Icons.ts │ └── Route.ts ├── types.ts ├── ext │ └── location │ │ ├── index.ts │ │ ├── LocationInfoProvider.ts │ │ ├── NominatimLocationInfoProvider.ts │ │ └── GoogleLocationInfoProvider.ts ├── repos │ ├── UserRoles.ts │ ├── index.ts │ ├── UserDevices.ts │ ├── Stats.ts │ ├── UserSessions.ts │ └── Users.ts ├── errors.ts ├── globals.ts ├── Secrets.ts ├── App.ts ├── config │ └── Geocode.ts └── auth.ts ├── .drone ├── build-all.sh ├── github-mirror.sh └── macros │ ├── configure-git.sh │ └── configure-ssh.sh ├── .pre-commit-config.yaml ├── Makefile ├── .gitignore ├── .github └── dependabot.yml ├── .drone.yml ├── tsconfig.json ├── docker-compose.yml ├── package.json ├── Dockerfile └── .env.example /frontend/.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /frontend/src/components/vars.scss: -------------------------------------------------------------------------------- 1 | $timeline-height: 10rem; 2 | -------------------------------------------------------------------------------- /frontend/src/styles/variables.scss: -------------------------------------------------------------------------------- 1 | $header-height: 3.5rem; 2 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | import Db from "./Db"; 2 | 3 | export { Db }; 4 | -------------------------------------------------------------------------------- /frontend/src/models/StatsRequest.ts: -------------------------------------------------------------------------------- 1 | ../../../src/requests/StatsRequest.ts -------------------------------------------------------------------------------- /frontend/src/models/LocationStats.ts: -------------------------------------------------------------------------------- 1 | ../../../src/responses/LocationStats.ts -------------------------------------------------------------------------------- /frontend/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | 3 | all: 4 | npm install && \ 5 | npm run build 6 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import App from './App'; 2 | 3 | const app = App.fromEnv(); 4 | app.listen(); 5 | -------------------------------------------------------------------------------- /frontend/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare const __API_PATH__: string; 3 | -------------------------------------------------------------------------------- /.drone/build-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | npm install typescript -g 4 | apk add --no-cache make 5 | make 6 | -------------------------------------------------------------------------------- /frontend/src/models/Types.ts: -------------------------------------------------------------------------------- 1 | type Optional = T | null; 2 | 3 | export { 4 | type Optional, 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blacklight/gpstracker/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /src/responses/index.ts: -------------------------------------------------------------------------------- 1 | import LocationStats from "./LocationStats" 2 | 3 | export { 4 | LocationStats, 5 | } 6 | -------------------------------------------------------------------------------- /src/models/RoleName.ts: -------------------------------------------------------------------------------- 1 | enum RoleName { 2 | Admin = 'admin', 3 | User = 'user', 4 | } 5 | 6 | export default RoleName; 7 | -------------------------------------------------------------------------------- /frontend/public/icons/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blacklight/gpstracker/HEAD/frontend/public/icons/pwa-192x192.png -------------------------------------------------------------------------------- /frontend/public/icons/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blacklight/gpstracker/HEAD/frontend/public/icons/pwa-512x512.png -------------------------------------------------------------------------------- /src/helpers/logging.ts: -------------------------------------------------------------------------------- 1 | export function logRequest(req: any) { 2 | console.log(`[${req.ip}] ${req.method} ${req.url}`); 3 | } 4 | -------------------------------------------------------------------------------- /frontend/public/icons/pwa-maskable-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blacklight/gpstracker/HEAD/frontend/public/icons/pwa-maskable-192x192.png -------------------------------------------------------------------------------- /frontend/public/icons/pwa-maskable-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blacklight/gpstracker/HEAD/frontend/public/icons/pwa-maskable-512x512.png -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/Lucas-C/pre-commit-hooks-nodejs 3 | rev: v1.1.2 4 | hooks: 5 | - id: markdown-toc 6 | 7 | -------------------------------------------------------------------------------- /frontend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "$schema": "https://json.schemastore.org/prettierrc", 4 | "semi": false, 5 | "singleQuote": true, 6 | "printWidth": 100 7 | } 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all backend frontend 2 | all: backend frontend 3 | 4 | backend: 5 | npm install && \ 6 | npm run build 7 | 8 | frontend: 9 | cd frontend && make 10 | -------------------------------------------------------------------------------- /src/requests/index.ts: -------------------------------------------------------------------------------- 1 | import LocationRequest from "./LocationRequest"; 2 | import StatsRequest from "./StatsRequest"; 3 | 4 | export { 5 | LocationRequest, 6 | StatsRequest, 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/styles/common.scss: -------------------------------------------------------------------------------- 1 | @forward "./animations.scss"; 2 | @forward "./elements.scss"; 3 | @forward "./layout.scss"; 4 | @forward "./variables.scss"; 5 | @forward "./views.scss"; 6 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.node.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/routes/api/v1/Route.ts: -------------------------------------------------------------------------------- 1 | import ApiRoute from '../Route'; 2 | 3 | abstract class ApiV1Route extends ApiRoute { 4 | constructor(path: string) { 5 | super(path, 'v1'); 6 | } 7 | } 8 | 9 | export default ApiV1Route; 10 | -------------------------------------------------------------------------------- /src/helpers/security.ts: -------------------------------------------------------------------------------- 1 | function maskPassword(obj: Record): Record { 2 | if (obj.password) { 3 | delete obj.password; 4 | } 5 | 6 | return obj; 7 | } 8 | 9 | export { 10 | maskPassword, 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/mixins/Notifications.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}] 2 | charset = utf-8 3 | indent_size = 2 4 | indent_style = space 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | 8 | end_of_line = lf 9 | max_line_length = 100 10 | -------------------------------------------------------------------------------- /src/routes/api/index.ts: -------------------------------------------------------------------------------- 1 | import ApiV1Routes from "./v1"; 2 | import Routes from "../Routes"; 3 | 4 | class ApiRoutes extends Routes { 5 | private v1: ApiV1Routes = new ApiV1Routes(); 6 | public routes = [...this.v1.routes]; 7 | } 8 | 9 | export default ApiRoutes; 10 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import { AuthInfo } from './auth'; 4 | 5 | type Optional = T | null | undefined; 6 | type RequestHandler = (req: Request, res: Response) => Promise; 7 | 8 | export { 9 | Optional, 10 | RequestHandler, 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/mixins/Units.vue: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /src/routes/Routes.ts: -------------------------------------------------------------------------------- 1 | import { Express } from 'express'; 2 | 3 | import Route from './Route'; 4 | 5 | abstract class Routes { 6 | public abstract routes: Route[]; 7 | 8 | public register(app: Express) { 9 | this.routes.forEach((route) => { 10 | route.register(app); 11 | }); 12 | } 13 | } 14 | 15 | export default Routes; 16 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 7 | 8 | "paths": { 9 | "@/*": ["./src/*"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/ext/location/index.ts: -------------------------------------------------------------------------------- 1 | import GoogleLocationInfoProvider from "./GoogleLocationInfoProvider"; 2 | import LocationInfoProvider from "./LocationInfoProvider"; 3 | import NominatimLocationInfoProvider from "./NominatimLocationInfoProvider"; 4 | 5 | export { 6 | GoogleLocationInfoProvider, 7 | LocationInfoProvider, 8 | NominatimLocationInfoProvider, 9 | }; 10 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | import GPSPoint from "./GPSPoint"; 2 | import Role from "./Role"; 3 | import RoleName from "./RoleName"; 4 | import User from "./User"; 5 | import UserDevice from "./UserDevice"; 6 | import UserSession from "./UserSession"; 7 | 8 | export { 9 | GPSPoint, 10 | Role, 11 | RoleName, 12 | User, 13 | UserDevice, 14 | UserSession, 15 | }; 16 | -------------------------------------------------------------------------------- /src/repos/UserRoles.ts: -------------------------------------------------------------------------------- 1 | import { Role, RoleName } from '../models'; 2 | 3 | class UserRoles { 4 | private static userRoleNames: string[] = Object.values(RoleName); 5 | 6 | public async init(): Promise { 7 | UserRoles.userRoleNames.forEach(async (name) => { 8 | new Role({ name }).save(); 9 | }); 10 | } 11 | } 12 | 13 | export default UserRoles; 14 | -------------------------------------------------------------------------------- /frontend/src/views/Logout.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | GPS Tracker 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/mixins/api/Users.vue: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import ApiRoutes from "./api"; 2 | import IconRoutes from "./Icons"; 3 | import Routes from "./Routes"; 4 | 5 | class AllRoutes extends Routes { 6 | private api: ApiRoutes = new ApiRoutes(); 7 | private icons: IconRoutes = new IconRoutes(); 8 | 9 | public routes = [ 10 | ...this.api.routes, 11 | ...this.icons.routes, 12 | ]; 13 | } 14 | 15 | export default AllRoutes; 16 | -------------------------------------------------------------------------------- /frontend/src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | 17 | 25 | -------------------------------------------------------------------------------- /frontend/src/mixins/Clipboard.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /frontend/src/mixins/Text.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /frontend/src/models/AuthInfo.ts: -------------------------------------------------------------------------------- 1 | import type { Optional } from "./Types"; 2 | import User from "./User"; 3 | import UserSession from "./UserSession"; 4 | 5 | class AuthInfo { 6 | public user: Optional; 7 | public userSession: Optional; 8 | 9 | constructor(user?: User, userSession?: UserSession) { 10 | this.user = user || null; 11 | this.userSession = userSession || null; 12 | } 13 | } 14 | 15 | export default AuthInfo; 16 | -------------------------------------------------------------------------------- /src/helpers/random.ts: -------------------------------------------------------------------------------- 1 | function randomString(length: number) { 2 | let result = ''; 3 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 4 | const charactersLength = characters.length; 5 | let counter = 0; 6 | while (counter < length) { 7 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 8 | counter += 1; 9 | } 10 | return result; 11 | } 12 | 13 | export { randomString }; 14 | -------------------------------------------------------------------------------- /frontend/src/mixins/Api.vue: -------------------------------------------------------------------------------- 1 | 20 | -------------------------------------------------------------------------------- /frontend/src/mixins/Dropdowns.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | -------------------------------------------------------------------------------- /frontend/src/models/AutocompleteValue.ts: -------------------------------------------------------------------------------- 1 | class AutocompleteValue { 2 | value: string; 3 | label: string; 4 | data?: any | null = undefined; 5 | 6 | constructor(record: { 7 | value: string; 8 | label: string; 9 | data?: any | null; 10 | }) { 11 | this.value = record.value; 12 | this.label = record.label; 13 | this.data = record.data; 14 | } 15 | 16 | toString(): string { 17 | return this.label; 18 | } 19 | } 20 | 21 | export default AutocompleteValue; 22 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node22/tsconfig.json", 3 | "include": [ 4 | "vite.config.*", 5 | "vitest.config.*", 6 | "cypress.config.*", 7 | "nightwatch.conf.*", 8 | "playwright.config.*", 9 | "eslint.config.*" 10 | ], 11 | "compilerOptions": { 12 | "noEmit": true, 13 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 14 | 15 | "module": "ESNext", 16 | "moduleResolution": "Bundler", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | .env 11 | node_modules 12 | .DS_Store 13 | dist 14 | dist-ssr 15 | coverage 16 | *.local 17 | 18 | # Editor directories and files 19 | .vscode 20 | .idea 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | *.vim 27 | *.tsbuildinfo 28 | 29 | # Database files 30 | *.db 31 | *.sqlite 32 | *.sqlite3 33 | data/ 34 | 35 | # HTTP request files 36 | *.http 37 | *env.json 38 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | abstract class ApplicationError extends Error { 2 | constructor(public message: string) { 3 | super(message); 4 | } 5 | } 6 | 7 | class BadRequest extends ApplicationError { } 8 | class ValidationError extends BadRequest { } 9 | class Unauthorized extends BadRequest { } 10 | class Forbidden extends BadRequest { } 11 | class NotFound extends BadRequest { } 12 | 13 | export { 14 | ApplicationError, 15 | BadRequest, 16 | Forbidden, 17 | NotFound, 18 | Unauthorized, 19 | ValidationError, 20 | }; 21 | -------------------------------------------------------------------------------- /src/db/types/Role.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, } from 'sequelize'; 2 | 3 | function Role(): Record { 4 | return { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | allowNull: false, 9 | autoIncrement: true, 10 | }, 11 | name: { 12 | type: DataTypes.STRING, 13 | allowNull: false, 14 | unique: true 15 | }, 16 | createdAt: { 17 | type: DataTypes.DATE, 18 | allowNull: false, 19 | defaultValue: () => new Date(), 20 | }, 21 | }; 22 | } 23 | 24 | export default Role; 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /frontend/src/mixins/api/Stats.vue: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /src/globals.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | 3 | import { Db } from './db'; 4 | import Geocode from './config/Geocode'; 5 | import Secrets from './Secrets'; 6 | import Repositories from './repos'; 7 | 8 | dotenv.config(); 9 | 10 | declare global { 11 | var $db: Db; 12 | var $repos: Repositories; 13 | var $secrets: Secrets; 14 | var $geocode: Geocode; 15 | } 16 | 17 | export function useGlobals() { 18 | globalThis.$secrets = Secrets.fromEnv(); 19 | globalThis.$db = Db.fromEnv(); 20 | globalThis.$geocode = Geocode.fromEnv(); 21 | globalThis.$repos = new Repositories(); 22 | } 23 | -------------------------------------------------------------------------------- /src/responses/LocationStats.ts: -------------------------------------------------------------------------------- 1 | class LocationStats { 2 | public key: Record; 3 | public count: number; 4 | public startDate: Date | undefined | null; 5 | public endDate: Date | undefined | null; 6 | 7 | constructor(data: { 8 | key: Record; 9 | count: number; 10 | startDate?: Date | undefined | null; 11 | endDate?: Date | undefined | null; 12 | }) { 13 | this.key = data.key; 14 | this.count = data.count; 15 | this.startDate = data.startDate; 16 | this.endDate = data.endDate; 17 | } 18 | } 19 | 20 | export default LocationStats; 21 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: pipeline 3 | type: docker 4 | name: default 5 | 6 | steps: 7 | 8 | ### 9 | ### Mirror the current repository state to Github 10 | ### 11 | 12 | - name: github-mirror 13 | image: alpine 14 | environment: 15 | SSH_PUBKEY: 16 | from_secret: ssh_pubkey 17 | SSH_PRIVKEY: 18 | from_secret: ssh_privkey 19 | 20 | commands: 21 | - . .drone/github-mirror.sh 22 | 23 | ### 24 | ### Run a smoke test of the application by trying a full build 25 | ### 26 | 27 | - name: build 28 | image: node:current-alpine3.20 29 | commands: 30 | - . .drone/build-all.sh 31 | -------------------------------------------------------------------------------- /frontend/src/models/UserDevice.ts: -------------------------------------------------------------------------------- 1 | import type { Optional } from "./Types"; 2 | 3 | class UserDevice { 4 | public id: string; 5 | public userId: number; 6 | public name: string; 7 | public createdAt?: Optional; 8 | 9 | constructor({ 10 | id, 11 | userId, 12 | name, 13 | createdAt = null, 14 | }: { 15 | id: string, 16 | userId: number, 17 | name: string, 18 | createdAt?: Optional, 19 | }) { 20 | this.id = id; 21 | this.userId = userId; 22 | this.name = name; 23 | this.createdAt = createdAt; 24 | } 25 | } 26 | 27 | export default UserDevice; 28 | -------------------------------------------------------------------------------- /frontend/src/assets/main.css: -------------------------------------------------------------------------------- 1 | @import './base.css'; 2 | 3 | html { 4 | height: 100%; 5 | } 6 | 7 | #app { 8 | width: 100%; 9 | height: 100%; 10 | font-weight: normal; 11 | } 12 | 13 | a, 14 | .green { 15 | text-decoration: none; 16 | color: hsla(160, 100%, 37%, 1); 17 | transition: 0.4s; 18 | padding: 3px; 19 | } 20 | 21 | @media (hover: hover) { 22 | a:hover { 23 | background-color: hsla(160, 100%, 37%, 0.2); 24 | } 25 | } 26 | 27 | @media (min-width: 1024px) { 28 | body { 29 | display: flex; 30 | place-items: center; 31 | } 32 | 33 | #app { 34 | display: flex; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/db/types/UserRole.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, } from 'sequelize'; 2 | 3 | function UserRole(): Record { 4 | return { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | userId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | }, 14 | roleId: { 15 | type: DataTypes.INTEGER, 16 | allowNull: false, 17 | }, 18 | createdAt: { 19 | type: DataTypes.DATE, 20 | allowNull: false, 21 | defaultValue: () => new Date(), 22 | }, 23 | }; 24 | } 25 | 26 | export default UserRole; 27 | -------------------------------------------------------------------------------- /src/db/Migrations.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize, } from 'sequelize'; 2 | import { Umzug, SequelizeStorage, } from 'umzug'; 3 | 4 | class Migrations { 5 | private readonly migrations: Umzug; 6 | 7 | constructor(db: Sequelize) { 8 | this.migrations = new Umzug({ 9 | storage: new SequelizeStorage({ sequelize: db }), 10 | context: db.getQueryInterface(), 11 | migrations: { 12 | glob: '**/db/migrations/*.js', 13 | }, 14 | logger: console, 15 | }) as Umzug; 16 | } 17 | 18 | public async up(): Promise { 19 | await this.migrations.up(); 20 | } 21 | } 22 | 23 | export default Migrations; 24 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # GPSTracker [frontend] 2 | 3 | This is the root folder for the GPSTracker frontend. 4 | 5 | See [main `README`](../README.md) for more information. 6 | 7 | ## Building the frontend 8 | 9 | ```sh 10 | make 11 | ``` 12 | 13 | ## Configuration 14 | 15 | Imported from the root level - see [`.env.example`](../.env.example). 16 | 17 | ## Running the frontend 18 | 19 | ```sh 20 | npm run start 21 | ``` 22 | 23 | ## Project Setup 24 | 25 | ### Compile and Hot-Reload for Development 26 | 27 | ```sh 28 | cd frontend 29 | npm run dev 30 | ``` 31 | 32 | ### Lint with [ESLint](https://eslint.org/) 33 | 34 | ```sh 35 | npm run lint 36 | ``` 37 | -------------------------------------------------------------------------------- /frontend/src/mixins/api/Auth.vue: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /src/db/types/UserDevice.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, } from 'sequelize'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | 4 | function UserDevice(): Record { 5 | return { 6 | id: { 7 | type: DataTypes.UUID, 8 | primaryKey: true, 9 | defaultValue: () => uuidv4(), 10 | }, 11 | userId: { 12 | type: DataTypes.INTEGER, 13 | allowNull: false, 14 | }, 15 | name: { 16 | type: DataTypes.STRING, 17 | allowNull: false, 18 | }, 19 | createdAt: { 20 | type: DataTypes.DATE, 21 | allowNull: false, 22 | defaultValue: () => new Date(), 23 | }, 24 | }; 25 | } 26 | 27 | export default UserDevice; 28 | -------------------------------------------------------------------------------- /.drone/github-mirror.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | . .drone/macros/configure-git.sh 4 | . .drone/macros/configure-ssh.sh 5 | 6 | export PROJECT_NAME="gpstracker" 7 | ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null 8 | 9 | # Clone the repository 10 | branch=$(git rev-parse --abbrev-ref HEAD) 11 | if [ -z "${branch}" ]; then 12 | echo "No branch checked out" 13 | exit 1 14 | fi 15 | 16 | git remote add github git@github.com:/blacklight/${PROJECT_NAME}.git 17 | 18 | if [[ "$branch" == "main" ]]; then 19 | git pull --rebase github "${branch}" || echo "No such branch on Github" 20 | fi 21 | 22 | # Push the changes to the GitHub mirror 23 | git push -f --all -v github 24 | git push --tags -v github 25 | -------------------------------------------------------------------------------- /frontend/src/styles/animations.scss: -------------------------------------------------------------------------------- 1 | @keyframes slide-up { 2 | from { 3 | transform: translateY(100%); 4 | } 5 | to { 6 | transform: translateY(0); 7 | } 8 | } 9 | 10 | @keyframes slide-down { 11 | from { 12 | transform: translateY(0); 13 | } 14 | to { 15 | transform: translateY(100%); 16 | } 17 | } 18 | 19 | @keyframes fade-in { 20 | from { 21 | opacity: 0; 22 | } 23 | to { 24 | opacity: 1; 25 | } 26 | } 27 | 28 | @keyframes fade-out { 29 | from { 30 | opacity: 1; 31 | } 32 | to { 33 | opacity: 0; 34 | } 35 | } 36 | 37 | @keyframes unroll { 38 | from { 39 | transform: translateY(7.5em); 40 | } 41 | to { 42 | transform: translateY(0); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/routes/api/v1/index.ts: -------------------------------------------------------------------------------- 1 | import Auth from "./Auth"; 2 | import Devices from "./Devices"; 3 | import DevicesById from "./DevicesById"; 4 | import GPSData from "./GPSData"; 5 | import LocationInfo from "./LocationInfo"; 6 | import Routes from "../../Routes"; 7 | import Stats from "./Stats"; 8 | import Tokens from "./Tokens"; 9 | import TokensById from "./TokensById"; 10 | import UserSelf from "./UserSelf"; 11 | 12 | class ApiV1Routes extends Routes { 13 | public routes = [ 14 | new Auth(), 15 | new Devices(), 16 | new DevicesById(), 17 | new GPSData(), 18 | new LocationInfo(), 19 | new Stats(), 20 | new Tokens(), 21 | new TokensById(), 22 | new UserSelf(), 23 | ]; 24 | } 25 | 26 | export default ApiV1Routes; 27 | -------------------------------------------------------------------------------- /frontend/src/models/User.ts: -------------------------------------------------------------------------------- 1 | import type { Optional } from "./Types"; 2 | 3 | class User { 4 | public id: number; 5 | public username: number; 6 | public email: string; 7 | public firstName: Optional; 8 | public lastName: Optional; 9 | public createdAt: Optional; 10 | 11 | constructor(user: { 12 | id: number; 13 | username: number; 14 | email: string; 15 | firstName?: string; 16 | lastName?: string; 17 | createdAt?: Date; 18 | }) { 19 | this.id = user.id; 20 | this.username = user.username; 21 | this.email = user.email; 22 | this.firstName = user.firstName || null; 23 | this.lastName = user.lastName || null; 24 | this.createdAt = user.createdAt || null; 25 | } 26 | } 27 | 28 | export default User; 29 | -------------------------------------------------------------------------------- /src/repos/index.ts: -------------------------------------------------------------------------------- 1 | import Location from './Location'; 2 | import Stats from './Stats'; 3 | import Users from './Users'; 4 | import UserDevices from './UserDevices'; 5 | import UserRoles from './UserRoles'; 6 | import UserSessions from './UserSessions'; 7 | 8 | class Repositories { 9 | public location: Location; 10 | public stats: Stats; 11 | public users: Users; 12 | public userDevices: UserDevices; 13 | public userRoles: UserRoles; 14 | public userSessions: UserSessions; 15 | 16 | constructor() { 17 | this.location = new Location(); 18 | this.stats = new Stats(); 19 | this.users = new Users(); 20 | this.userDevices = new UserDevices(); 21 | this.userRoles = new UserRoles(); 22 | this.userSessions = new UserSessions(); 23 | } 24 | } 25 | 26 | export default Repositories; 27 | -------------------------------------------------------------------------------- /src/requests/StatsRequest.ts: -------------------------------------------------------------------------------- 1 | type Order = 'ASC' | 'DESC'; 2 | type GroupBy = 'device' | 'country' | 'locality' | 'postalCode' | 'description'; 3 | 4 | class StatsRequest { 5 | userId: number; 6 | groupBy: GroupBy[]; 7 | orderBy: string = 'count'; 8 | order: Order = 'DESC'; 9 | 10 | constructor(req: { 11 | userId: number; 12 | groupBy: string[] | string; 13 | orderBy?: string; 14 | order?: string; 15 | }) { 16 | this.userId = req.userId; 17 | this.groupBy = ( 18 | typeof req.groupBy === 'string' ? 19 | req.groupBy.split(/\s*,\s*/) : 20 | req.groupBy 21 | ) as GroupBy[]; 22 | 23 | this.orderBy = req.orderBy || this.orderBy; 24 | this.order = (req.order || this.order).toUpperCase() as Order; 25 | } 26 | } 27 | 28 | export default StatsRequest; 29 | -------------------------------------------------------------------------------- /frontend/src/models/Message.ts: -------------------------------------------------------------------------------- 1 | class Message { 2 | public id: string; 3 | public content: string; 4 | public icon?: string; 5 | public isError: boolean; 6 | public timeout?: number; 7 | public onClick: (...args: any[]) => void = () => {}; 8 | 9 | constructor(msg: { 10 | id?: string; 11 | content: string; 12 | icon?: string; 13 | isError?: boolean; 14 | timeout?: number; 15 | onClick?: (...args: any[]) => void; 16 | }) { 17 | this.id = msg.id || Math.random().toString(36).substring(2, 11); 18 | this.content = msg.content; 19 | this.icon = msg.icon; 20 | this.isError = msg.isError || false; 21 | this.timeout = msg.timeout !== undefined ? msg.timeout : 5000; 22 | this.onClick = msg.onClick || this.onClick; 23 | } 24 | } 25 | 26 | export default Message; 27 | -------------------------------------------------------------------------------- /src/ext/location/LocationInfoProvider.ts: -------------------------------------------------------------------------------- 1 | import { GPSPoint } from "~/models"; 2 | import { useGlobals } from '../../globals'; 3 | import GoogleLocationInfoProvider from "./GoogleLocationInfoProvider"; 4 | import NominatimLocationInfoProvider from "./NominatimLocationInfoProvider"; 5 | 6 | useGlobals(); 7 | 8 | abstract class LocationInfoProvider { 9 | // TODO Cache location info 10 | abstract getLocationInfo: (location: GPSPoint) => Promise; 11 | 12 | static get(): LocationInfoProvider | undefined { 13 | switch ($geocode.provider) { 14 | case 'nominatim': 15 | return new NominatimLocationInfoProvider(); 16 | case 'google': 17 | return new GoogleLocationInfoProvider(); 18 | } 19 | 20 | return undefined; 21 | }; 22 | } 23 | 24 | export default LocationInfoProvider; 25 | -------------------------------------------------------------------------------- /frontend/src/elements/Loading.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 11 | 12 | 41 | -------------------------------------------------------------------------------- /frontend/src/models/UserSession.ts: -------------------------------------------------------------------------------- 1 | import type { Optional } from "./Types"; 2 | 3 | class UserSession { 4 | public id: string; 5 | public userId: number; 6 | public name: string; 7 | public isApi: boolean; 8 | public createdAt: Date; 9 | public expiresAt: Optional; 10 | 11 | constructor({ 12 | id, 13 | userId, 14 | name, 15 | isApi = false, 16 | createdAt, 17 | expiresAt = null, 18 | }: { 19 | id: string; 20 | userId: number; 21 | name: string; 22 | isApi?: boolean; 23 | createdAt: Date; 24 | expiresAt?: Optional; 25 | }) { 26 | this.id = id; 27 | this.userId = userId; 28 | this.name = name; 29 | this.isApi = isApi; 30 | this.createdAt = createdAt; 31 | this.expiresAt = expiresAt || null; 32 | } 33 | } 34 | 35 | export default UserSession; 36 | -------------------------------------------------------------------------------- /src/routes/api/v1/UserSelf.ts: -------------------------------------------------------------------------------- 1 | import { Optional } from '../../../types'; 2 | import { Request, Response } from 'express'; 3 | 4 | import ApiV1Route from './Route'; 5 | import { AuthInfo, authenticate } from '../../../auth'; 6 | import { maskPassword } from '../../../helpers/security'; 7 | 8 | class UserSelf extends ApiV1Route { 9 | constructor() { 10 | super('/users/me'); 11 | } 12 | 13 | /** 14 | * GET /users/me 15 | * 16 | * It returns a JSON object with the user and session information. 17 | */ 18 | @authenticate() 19 | get = async (_: Request, res: Response, auth: Optional) => { 20 | const user = auth!.user; 21 | const session = auth!.session; 22 | 23 | res.json({ 24 | user: maskPassword(user), 25 | ...(session ? { session } : {}), 26 | }); 27 | } 28 | } 29 | 30 | export default UserSelf; 31 | -------------------------------------------------------------------------------- /frontend/src/components/api/TokensList.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 34 | 35 | 38 | -------------------------------------------------------------------------------- /frontend/src/mixins/MapView.vue: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /src/db/types/UserSession.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes } from 'sequelize'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | 4 | function UserSession(): Record { 5 | return { 6 | id: { 7 | type: DataTypes.UUID, 8 | primaryKey: true, 9 | allowNull: false, 10 | defaultValue: () => uuidv4(), 11 | }, 12 | userId: { 13 | type: DataTypes.INTEGER, 14 | allowNull: false, 15 | }, 16 | name: { 17 | type: DataTypes.STRING, 18 | allowNull: true, 19 | }, 20 | isApi: { 21 | type: DataTypes.BOOLEAN, 22 | allowNull: false, 23 | defaultValue: false, 24 | }, 25 | expiresAt: { 26 | type: DataTypes.DATE, 27 | allowNull: true, 28 | }, 29 | createdAt: { 30 | type: DataTypes.DATE, 31 | allowNull: false, 32 | defaultValue: () => new Date(), 33 | }, 34 | } 35 | } 36 | 37 | export default UserSession; 38 | -------------------------------------------------------------------------------- /frontend/eslint.config.ts: -------------------------------------------------------------------------------- 1 | import pluginVue from 'eslint-plugin-vue' 2 | import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript' 3 | import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' 4 | 5 | // To allow more languages other than `ts` in `.vue` files, uncomment the following lines: 6 | // import { configureVueProject } from '@vue/eslint-config-typescript' 7 | // configureVueProject({ scriptLangs: ['ts', 'tsx'] }) 8 | // More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup 9 | 10 | export default defineConfigWithVueTs( 11 | { 12 | name: 'app/files-to-lint', 13 | files: ['**/*.{ts,mts,tsx,vue}'], 14 | }, 15 | 16 | { 17 | name: 'app/files-to-ignore', 18 | ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'], 19 | }, 20 | 21 | pluginVue.configs['flat/essential'], 22 | vueTsConfigs.recommended, 23 | skipFormatting, 24 | ) 25 | -------------------------------------------------------------------------------- /.drone/macros/configure-git.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Install git 4 | if [ -z "$(which git)" ]; then 5 | if [ -n "$(which apt-get)" ]; then 6 | apt-get update 7 | apt-get install -y git 8 | elif [ -n "$(which apk)" ]; then 9 | apk add --update --no-cache git 10 | elif [ -n "$(which yum)" ]; then 11 | yum install -y git 12 | elif [ -n "$(which dnf)" ]; then 13 | dnf install -y git 14 | elif [ -n "$(which pacman)" ]; then 15 | pacman -Sy --noconfirm git 16 | else 17 | echo "Could not find a package manager to install git" 18 | exit 1 19 | fi 20 | fi 21 | 22 | # Backup the original git configuration before changing attributes 23 | export GIT_CONF="$PWD/.git/config" 24 | export TMP_GIT_CONF=/tmp/git.config.orig 25 | cp "$GIT_CONF" "$TMP_GIT_CONF" 26 | 27 | git config --global --add safe.directory "$PWD" 28 | git config user.name "CI/CD Automation" 29 | git config user.email "admin@platypush.tech" 30 | -------------------------------------------------------------------------------- /src/db/types/User.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes } from 'sequelize'; 2 | 3 | function User(): Record { 4 | return { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | username: { 11 | type: DataTypes.STRING, 12 | allowNull: false, 13 | unique: true 14 | }, 15 | email: { 16 | type: DataTypes.STRING, 17 | allowNull: false, 18 | unique: true 19 | }, 20 | password: { 21 | type: DataTypes.STRING, 22 | allowNull: false 23 | }, 24 | firstName: { 25 | type: DataTypes.STRING, 26 | allowNull: false 27 | }, 28 | lastName: { 29 | type: DataTypes.STRING, 30 | allowNull: false 31 | }, 32 | createdAt: { 33 | type: DataTypes.DATE, 34 | allowNull: false, 35 | defaultValue: () => new Date(), 36 | }, 37 | }; 38 | } 39 | 40 | export default User; 41 | -------------------------------------------------------------------------------- /frontend/src/components/devices/DevicesList.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 36 | 37 | 40 | -------------------------------------------------------------------------------- /frontend/src/elements/DropdownItem.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | 18 | 49 | -------------------------------------------------------------------------------- /src/routes/api/v1/Stats.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import { authenticate } from '../../../auth'; 4 | import { AuthInfo } from '../../../auth'; 5 | import { Optional } from '../../../types'; 6 | import { StatsRequest } from '../../../requests'; 7 | import ApiV1Route from './Route'; 8 | 9 | class Stats extends ApiV1Route { 10 | constructor() { 11 | super('/stats'); 12 | } 13 | 14 | @authenticate() 15 | get = async (req: Request, res: Response, auth: Optional) => { 16 | let query: StatsRequest; 17 | 18 | try { 19 | query = new StatsRequest({ 20 | ...req.query as any, 21 | userId: auth!.user.id, 22 | }); 23 | } catch (error) { 24 | const e = `Error parsing query: ${error}`; 25 | console.warn(e); 26 | res.status(400).send(e); 27 | return; 28 | } 29 | 30 | res.json(await $repos.stats.get(query)); 31 | } 32 | } 33 | 34 | export default Stats; 35 | -------------------------------------------------------------------------------- /frontend/src/models/Country.ts: -------------------------------------------------------------------------------- 1 | import type { TCountryCode } from 'countries-list'; 2 | import { getCountryData, getEmojiFlag } from 'countries-list'; 3 | 4 | class Country { 5 | name: string; 6 | code: string; 7 | continent: string; 8 | flag: string; 9 | 10 | constructor(data: { 11 | name: string; 12 | code: string; 13 | continent: string; 14 | flag: string; 15 | }) { 16 | this.name = data.name; 17 | this.code = data.code; 18 | this.continent = data.continent; 19 | this.flag = data.flag; 20 | } 21 | 22 | public static fromCode(code: string | TCountryCode): Country | null { 23 | const cc = code.toUpperCase() as TCountryCode; 24 | const countryData = getCountryData(cc); 25 | if (!countryData) return null; 26 | 27 | return new Country({ 28 | name: countryData.name, 29 | code: code, 30 | continent: countryData.continent, 31 | flag: getEmojiFlag(cc), 32 | }); 33 | } 34 | } 35 | 36 | export default Country; 37 | -------------------------------------------------------------------------------- /frontend/src/elements/ConfirmDialog.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 39 | 40 | 42 | -------------------------------------------------------------------------------- /src/models/UserDevice.ts: -------------------------------------------------------------------------------- 1 | import { Optional } from '~/types'; 2 | 3 | class UserDevice { 4 | public id: string; 5 | public userId: number; 6 | public name: string; 7 | public createdAt: Optional; 8 | 9 | constructor({ 10 | id, 11 | userId, 12 | name, 13 | createdAt = null, 14 | }: { 15 | id: string; 16 | userId: number; 17 | name: string; 18 | createdAt?: Optional; 19 | }) { 20 | this.id = id; 21 | this.name = name; 22 | this.userId = userId; 23 | this.createdAt = createdAt ? new Date(createdAt) : null; 24 | } 25 | 26 | public async destroy() { 27 | await $db.UserDevice().destroy({ 28 | where: { 29 | id: this.id, 30 | }, 31 | }); 32 | } 33 | 34 | public async save() { 35 | await $db.UserDevice().update( 36 | { 37 | name: this.name, 38 | }, 39 | { 40 | where: { 41 | id: this.id, 42 | }, 43 | } 44 | ); 45 | } 46 | } 47 | 48 | export default UserDevice; 49 | -------------------------------------------------------------------------------- /frontend/src/mixins/api/Sessions.vue: -------------------------------------------------------------------------------- 1 | 38 | -------------------------------------------------------------------------------- /.drone/macros/configure-ssh.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -z "$SSH_PUBKEY" ] || [ -z "$SSH_PRIVKEY" ]; then 4 | echo "SSH_PUBKEY and SSH_PRIVKEY environment variables must be set" 5 | exit 1 6 | fi 7 | 8 | # Install ssh 9 | if [ -z "$(which ssh)" ]; then 10 | if [ -n "$(which apt-get)" ]; then 11 | apt-get update 12 | apt-get install -y openssh 13 | elif [ -n "$(which apk)" ]; then 14 | apk add --update --no-cache openssh 15 | elif [ -n "$(which yum)" ]; then 16 | yum install -y openssh 17 | elif [ -n "$(which dnf)" ]; then 18 | dnf install -y openssh 19 | elif [ -n "$(which pacman)" ]; then 20 | pacman -Sy --noconfirm openssh 21 | else 22 | echo "Could not find a package manager to install openssh" 23 | exit 1 24 | fi 25 | fi 26 | 27 | mkdir -p ~/.ssh 28 | echo $SSH_PUBKEY > ~/.ssh/id_rsa.pub 29 | 30 | cat < ~/.ssh/id_rsa 31 | $SSH_PRIVKEY 32 | EOF 33 | 34 | chmod 0600 ~/.ssh/id_rsa 35 | ssh-keyscan git.platypush.tech >> ~/.ssh/known_hosts 2>/dev/null 36 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import './assets/main.css' 2 | 3 | import { createApp } from 'vue' 4 | import { useStorage } from '@vueuse/core' 5 | 6 | import App from './App.vue' 7 | import router from './router' 8 | 9 | import mitt from 'mitt' 10 | 11 | /* import the fontawesome core */ 12 | import { library } from '@fortawesome/fontawesome-svg-core' 13 | 14 | /* import font awesome icon component */ 15 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' 16 | 17 | /* import icon kits */ 18 | import { fas } from '@fortawesome/free-solid-svg-icons' 19 | import { far } from '@fortawesome/free-regular-svg-icons' 20 | 21 | /* add icons to the library */ 22 | library.add(fas, far) 23 | 24 | /* set up the storage */ 25 | const storage = useStorage('app-storage', { 26 | user: null, 27 | userSession: null, 28 | }) 29 | 30 | const app = createApp(App) 31 | 32 | app.component('font-awesome-icon', FontAwesomeIcon) 33 | app.use(router) 34 | app.config.globalProperties.$storage = storage 35 | app.config.globalProperties.$msgBus = mitt() 36 | app.mount('#app') 37 | -------------------------------------------------------------------------------- /src/helpers/cookies.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express'; 2 | 3 | import { Optional } from '../types'; 4 | import { ValidationError } from '../errors'; 5 | 6 | const setCookie = (res: Response, params: { 7 | name: string, 8 | value: string, 9 | expiresAt?: Optional, 10 | path?: string, 11 | }) => { 12 | params.path = params.path || '/'; 13 | if (params.expiresAt) { 14 | try { 15 | params.expiresAt = new Date(params.expiresAt); 16 | } catch (error) { 17 | throw new ValidationError(`Invalid expiresAt: ${error}`); 18 | } 19 | } 20 | 21 | let cookie = `${params.name}=${params.value}; Path=${params.path}; HttpOnly; SameSite=Strict`; 22 | if (params.expiresAt) { 23 | cookie += `; Expires=${params.expiresAt.toUTCString()}`; 24 | } 25 | 26 | res.setHeader('Set-Cookie', cookie); 27 | } 28 | 29 | const clearCookie = (res: Response, name: string) => { 30 | setCookie(res, { 31 | name, 32 | value: '', 33 | expiresAt: new Date(0), 34 | }); 35 | } 36 | 37 | export { 38 | clearCookie, 39 | setCookie, 40 | }; 41 | -------------------------------------------------------------------------------- /src/routes/api/v1/TokensById.ts: -------------------------------------------------------------------------------- 1 | import { Optional } from '../../../types'; 2 | import { Request, Response } from 'express'; 3 | 4 | import ApiV1Route from './Route'; 5 | import { AuthInfo, authenticate } from '../../../auth'; 6 | import { RoleName } from '../../../models'; 7 | 8 | class TokensById extends ApiV1Route { 9 | constructor() { 10 | super('/tokens/:id'); 11 | } 12 | 13 | /** 14 | * Delete an API token given its (session) ID. 15 | */ 16 | @authenticate() 17 | delete = async (req: Request, res: Response, auth?: Optional) => { 18 | const user = auth!.user; 19 | const sessionId = req.params.id; 20 | const session = await $repos.userSessions.find(sessionId); 21 | 22 | if (!session) { 23 | res.status(404).send(); 24 | return; 25 | } 26 | 27 | if (session.userId !== user.id) { 28 | // Only the owner of the token or admin users can delete it. 29 | authenticate([RoleName.Admin]); 30 | } 31 | 32 | await session.destroy(); 33 | res.status(204).send(); 34 | } 35 | } 36 | 37 | export default TokensById; 38 | -------------------------------------------------------------------------------- /src/routes/api/Route.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import Route from '../Route'; 4 | import { Unauthorized, } from '../../errors'; 5 | import { clearCookie } from '../../helpers/cookies'; 6 | 7 | abstract class ApiRoute extends Route { 8 | protected version: string; 9 | 10 | constructor(path: string, version: string) { 11 | super(ApiRoute.toApiPath(path, version)); 12 | } 13 | 14 | protected static handleError(req: Request, res: Response, error: Error) { 15 | // Handle API unauthorized errors with a 401+JSON response instead of a redirect 16 | if (error instanceof Unauthorized) { 17 | res.status(401) 18 | clearCookie(res, 'session'); 19 | res.json({ 20 | error: error.message, 21 | }); 22 | 23 | return; 24 | } 25 | 26 | return super.handleError(req, res, error); 27 | } 28 | 29 | protected static toApiPath(path: string, version: string): string { 30 | if (!path.startsWith('/')) { 31 | path = `/${path}`; 32 | } 33 | 34 | return `/api/${version}${path}`; 35 | } 36 | } 37 | 38 | export default ApiRoute; 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "lib": [ 6 | "dom", 7 | "es6", 8 | "es2017", 9 | "ES2021.String", 10 | "esnext.asynciterable" 11 | ], 12 | "skipLibCheck": true, 13 | "sourceMap": true, 14 | "outDir": "./dist", 15 | "moduleResolution": "node", 16 | "removeComments": true, 17 | "noImplicitAny": true, 18 | "strictNullChecks": true, 19 | "strictFunctionTypes": true, 20 | "noImplicitThis": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noImplicitReturns": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "allowSyntheticDefaultImports": true, 26 | "esModuleInterop": true, 27 | "emitDecoratorMetadata": true, 28 | "experimentalDecorators": true, 29 | "resolveJsonModule": true, 30 | "baseUrl": ".", 31 | "rootDir": "./src", 32 | "paths": { 33 | "~/*": ["./src/*"] 34 | } 35 | }, 36 | "exclude": ["node_modules"], 37 | "include": [ 38 | "./src/**/*.ts", 39 | "./src/**/*.d.ts" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /frontend/src/mixins/Geo.vue: -------------------------------------------------------------------------------- 1 | 37 | -------------------------------------------------------------------------------- /frontend/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import API from '../views/API.vue' 3 | import Devices from '../views/Devices.vue' 4 | import HomeView from '../views/HomeView.vue' 5 | import Login from '../views/Login.vue' 6 | import Logout from '../views/Logout.vue' 7 | import Stats from '../views/Stats.vue' 8 | 9 | const router = createRouter({ 10 | history: createWebHistory(import.meta.env.BASE_URL), 11 | routes: [ 12 | { 13 | path: '/', 14 | name: 'home', 15 | component: HomeView, 16 | }, 17 | 18 | { 19 | path: '/devices', 20 | name: 'devices', 21 | component: Devices, 22 | }, 23 | 24 | { 25 | path: '/api', 26 | name: 'api', 27 | component: API, 28 | }, 29 | 30 | { 31 | path: '/stats', 32 | name: 'stats', 33 | component: Stats, 34 | }, 35 | 36 | { 37 | path: '/login', 38 | name: 'login', 39 | component: Login, 40 | }, 41 | 42 | { 43 | path: '/logout', 44 | name: 'logout', 45 | component: Logout, 46 | }, 47 | ], 48 | }) 49 | 50 | export default router 51 | -------------------------------------------------------------------------------- /src/repos/UserDevices.ts: -------------------------------------------------------------------------------- 1 | import { Optional } from '~/types'; 2 | import { UserDevice } from '../models'; 3 | 4 | class UserDevices { 5 | public async get(deviceId: string): Promise> { 6 | let dbDevice = await $db.UserDevice().findByPk(deviceId) 7 | if (!dbDevice) { 8 | dbDevice = await $db.UserDevice().findOne({ 9 | where: { name: deviceId } 10 | }); 11 | 12 | if (!dbDevice) { 13 | return null; 14 | } 15 | } 16 | 17 | return new UserDevice(dbDevice.dataValues); 18 | } 19 | 20 | public async getAll(deviceIds: string[]): Promise { 21 | const dbDevices = await $db.UserDevice().findAll({ 22 | where: { 23 | name: deviceIds, 24 | } 25 | }); 26 | 27 | return dbDevices.map((d) => new UserDevice(d.dataValues)); 28 | } 29 | 30 | public async create(name: string, args: { 31 | userId: number, 32 | }): Promise { 33 | const device = await $db.UserDevice().create({ 34 | userId: args.userId, 35 | name: name, 36 | }); 37 | 38 | return new UserDevice(device.dataValues); 39 | } 40 | } 41 | 42 | export default UserDevices; 43 | -------------------------------------------------------------------------------- /src/models/GPSPoint.ts: -------------------------------------------------------------------------------- 1 | class GPSPoint { 2 | public id: number; 3 | public deviceId: string; 4 | public latitude: number; 5 | public longitude: number; 6 | public altitude: number | null; 7 | public address: string | null; 8 | public locality: string | null; 9 | public country: string | null; 10 | public postalCode: string | null; 11 | public description: string | null; 12 | public battery: number | null; 13 | public speed: number | null; 14 | public accuracy: number | null; 15 | public timestamp: Date; 16 | 17 | constructor(record: any) { 18 | this.id = record.id; 19 | this.deviceId = record.deviceId; 20 | this.latitude = record.latitude; 21 | this.longitude = record.longitude; 22 | this.altitude = record.altitude; 23 | this.address = record.address; 24 | this.locality = record.locality; 25 | this.country = record.country; 26 | this.postalCode = record.postalCode; 27 | this.description = record.description; 28 | this.battery = record.battery; 29 | this.speed = record.speed; 30 | this.accuracy = record.accuracy; 31 | this.timestamp = record.timestamp; 32 | } 33 | } 34 | 35 | export default GPSPoint; 36 | -------------------------------------------------------------------------------- /frontend/src/mixins/LocationQuery.vue: -------------------------------------------------------------------------------- 1 | 37 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | env_file: 7 | - ./.env 8 | environment: 9 | # Needed to make sure that the backend port can be exposed 10 | BACKEND_ADDRESS: 0.0.0.0 11 | ports: 12 | - 3000:3000 13 | depends_on: 14 | db: 15 | condition: service_healthy 16 | healthcheck: 17 | # If you change the backend port, remember to also change this URL 18 | test: curl -s 'http://localhost:3000' >/dev/null 19 | interval: 10s 20 | timeout: 5s 21 | retries: 5 22 | restart: unless-stopped 23 | 24 | db: 25 | image: postgres:17-alpine 26 | environment: 27 | # These should match the settings reported in your DB_URL env variable 28 | - POSTGRES_USER=gpstracker 29 | - POSTGRES_PASSWORD=gpstracker 30 | - POSTGRES_DB=gpstracker 31 | volumes: 32 | - data:/var/lib/postgresql/data/ 33 | expose: 34 | - 5432 35 | healthcheck: 36 | test: pg_isready -U gpstracker 37 | interval: 2s 38 | timeout: 5s 39 | retries: 10 40 | restart: unless-stopped 41 | 42 | volumes: 43 | data: 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "main.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node dist/main.js", 9 | "dev": "nodemon", 10 | "build": "tsc" 11 | }, 12 | "nodemonConfig": { 13 | "watch": [ 14 | "src" 15 | ], 16 | "ignore": [ 17 | "dist" 18 | ], 19 | "exec": "tsc && node ./dist/main.js", 20 | "ext": "ts,js,json" 21 | }, 22 | "keywords": [], 23 | "author": "", 24 | "license": "GPL-3.0", 25 | "type": "commonjs", 26 | "dependencies": { 27 | "bcryptjs": "^3.0.2", 28 | "body-parser": "^1.20.3", 29 | "cors": "^2.8.5", 30 | "dotenv": "^16.4.7", 31 | "express": "^4.21.2", 32 | "jsonwebtoken": "^9.0.2", 33 | "pg": "^8.13.3", 34 | "sequelize": "^6.37.5", 35 | "sqlite3": "^5.1.7", 36 | "umzug": "^3.8.2", 37 | "uuid": "^11.1.0" 38 | }, 39 | "devDependencies": { 40 | "@types/cors": "^2.8.17", 41 | "@types/express": "^5.0.0", 42 | "@types/jsonwebtoken": "^9.0.9", 43 | "@types/node": "^22.13.4", 44 | "nodemon": "^3.1.9", 45 | "typescript": "^5.7.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/mixins/api/GPSData.vue: -------------------------------------------------------------------------------- 1 | 40 | -------------------------------------------------------------------------------- /frontend/src/mixins/Paginate.vue: -------------------------------------------------------------------------------- 1 | 52 | -------------------------------------------------------------------------------- /src/models/Role.ts: -------------------------------------------------------------------------------- 1 | import { Op } from 'sequelize'; 2 | import { Optional } from '~/types'; 3 | import RoleName from './RoleName'; 4 | 5 | class Role { 6 | public id: number; 7 | public name: RoleName; 8 | public createdAt: Optional; 9 | 10 | constructor({ 11 | id, 12 | name, 13 | createdAt = null, 14 | }: any) { 15 | this.id = id; 16 | this.name = name; 17 | this.createdAt = createdAt; 18 | } 19 | 20 | public async get(role: number | string): Promise> { 21 | const dbRole = await $db.Role().findOne({ 22 | where: { 23 | [Op.or]: [ 24 | { id: role }, 25 | { name: role }, 26 | ], 27 | }, 28 | }); 29 | 30 | if (!dbRole) { 31 | return null; 32 | } 33 | 34 | return new Role(dbRole.dataValues); 35 | } 36 | 37 | public async save(): Promise { 38 | const userRole = await $db.Role().findOne({ 39 | where: { 40 | name: this.name, 41 | }, 42 | }); 43 | 44 | if (!userRole) { 45 | await $db.Role().create({ 46 | name: this.name, 47 | createdAt: new Date(), 48 | }); 49 | } else { 50 | await userRole.update({ 51 | name: this.name, 52 | }); 53 | } 54 | } 55 | } 56 | 57 | export default Role; 58 | -------------------------------------------------------------------------------- /frontend/src/components/filter/ToggleButton.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 25 | 26 | 52 | -------------------------------------------------------------------------------- /src/routes/api/v1/LocationInfo.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import { authenticate } from '../../../auth'; 4 | import { GPSPoint } from '../../../models'; 5 | import { LocationInfoProvider } from '../../../ext/location'; 6 | import ApiV1Route from './Route'; 7 | 8 | class LocationInfo extends ApiV1Route { 9 | private provider: LocationInfoProvider | undefined; 10 | 11 | constructor() { 12 | super('/location-info'); 13 | this.provider = LocationInfoProvider.get(); 14 | } 15 | 16 | @authenticate() 17 | get = async (req: Request, res: Response) => { 18 | if (!this.provider) { 19 | res.status(500).send('Location info provider not configured'); 20 | return; 21 | } 22 | 23 | let location: GPSPoint; 24 | 25 | try { 26 | location = new GPSPoint(req.query); 27 | if (!(location?.latitude && location?.longitude)) { 28 | res.status(400).send('Invalid GPS coordinates'); 29 | return; 30 | } 31 | } catch (error) { 32 | const e = `Error parsing location request: ${error}`; 33 | console.warn(e); 34 | res.status(400).send(e); 35 | return; 36 | } 37 | 38 | location = await this.provider.getLocationInfo(location); 39 | res.json(location); 40 | } 41 | } 42 | 43 | export default LocationInfo; 44 | -------------------------------------------------------------------------------- /frontend/src/mixins/api/Devices.vue: -------------------------------------------------------------------------------- 1 | 42 | -------------------------------------------------------------------------------- /src/Secrets.ts: -------------------------------------------------------------------------------- 1 | class Secrets { 2 | public readonly serverKey: string; 3 | public readonly adminPassword: string; 4 | public readonly adminEmail: string; 5 | public readonly googleApiKey?: string; 6 | 7 | private constructor(args: { 8 | serverKey: string; 9 | adminPassword: string; 10 | adminEmail: string; 11 | googleApiKey?: string; 12 | }) { 13 | this.serverKey = args.serverKey; 14 | this.adminPassword = args.adminPassword; 15 | this.adminEmail = args.adminEmail; 16 | this.googleApiKey = args.googleApiKey; 17 | } 18 | 19 | public static fromEnv(): Secrets { 20 | if (!process.env.SERVER_KEY?.length) { 21 | throw new Error( 22 | 'SERVER_KEY not found in environment.\n' + 23 | 'Generate one with `openssl rand -base64 32` and add it to your environment.' 24 | ); 25 | } 26 | 27 | if (!process.env.ADMIN_PASSWORD?.length) { 28 | throw new Error('ADMIN_PASSWORD not found in environment.'); 29 | } 30 | 31 | if (!process.env.ADMIN_EMAIL?.length) { 32 | throw new Error('ADMIN_EMAIL not found in environment.'); 33 | } 34 | 35 | return new Secrets({ 36 | serverKey: process.env.SERVER_KEY, 37 | adminPassword: process.env.ADMIN_PASSWORD, 38 | adminEmail: process.env.ADMIN_EMAIL, 39 | googleApiKey: process.env.GOOGLE_API_KEY, 40 | }); 41 | } 42 | } 43 | 44 | export default Secrets; 45 | -------------------------------------------------------------------------------- /src/routes/api/v1/Devices.ts: -------------------------------------------------------------------------------- 1 | import { Optional } from '../../../types'; 2 | import { Request, Response } from 'express'; 3 | 4 | import ApiV1Route from './Route'; 5 | import { AuthInfo, authenticate } from '../../../auth'; 6 | import { BadRequest } from '../../../errors'; 7 | 8 | class Devices extends ApiV1Route { 9 | constructor() { 10 | super('/devices'); 11 | } 12 | 13 | /** 14 | * GET /devices 15 | * 16 | * It returns a JSON object of devices associated with the authenticated user, 17 | * in the format `{ devices: UserDevice[] }`. 18 | */ 19 | @authenticate() 20 | get = async (_: Request, res: Response, auth: Optional) => { 21 | res.json({ 22 | devices: (await auth!.user.devices()), 23 | }); 24 | } 25 | 26 | /** 27 | * POST /devices 28 | * 29 | * It creates a new device associated with the authenticated user. 30 | * It expects a JSON object with the following properties: 31 | * - `name` (string): The name of the device. 32 | * It must be unique for the user. 33 | */ 34 | @authenticate() 35 | post = async (req: Request, res: Response, auth: Optional) => { 36 | const { name } = req.body; 37 | 38 | if (!name) { 39 | throw new BadRequest('Missing name'); 40 | } 41 | 42 | const device = await $repos.userDevices.create(name, { userId: auth!.user.id }); 43 | res.json(device); 44 | } 45 | } 46 | 47 | export default Devices; 48 | -------------------------------------------------------------------------------- /src/models/UserSession.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | 3 | import { Optional } from '~/types'; 4 | 5 | class UserSession { 6 | public id: string; 7 | public userId: number; 8 | public name?: Optional; 9 | public isApi: boolean; 10 | public expiresAt: Optional; 11 | public createdAt: Optional; 12 | 13 | constructor({ 14 | id, 15 | userId, 16 | name, 17 | isApi = false, 18 | expiresAt = null, 19 | createdAt = null, 20 | }: { 21 | id: string; 22 | userId: number; 23 | name?: Optional; 24 | isApi?: boolean; 25 | expiresAt?: Optional; 26 | createdAt?: Optional; 27 | }) { 28 | this.id = id; 29 | this.userId = userId; 30 | this.name = name; 31 | this.isApi = isApi; 32 | this.expiresAt = expiresAt; 33 | this.createdAt = createdAt; 34 | 35 | ['expiresAt', 'createdAt'].forEach((key) => { 36 | if ((this as any)[key] && !((this as any)[key] instanceof Date)) { 37 | (this as any)[key] = new Date((this as any)[key]); 38 | } 39 | }); 40 | } 41 | 42 | public getToken() { 43 | return jwt.sign({ 44 | sessionId: this.id, 45 | userId: this.userId, 46 | }, $secrets.serverKey); 47 | } 48 | 49 | public destroy() { 50 | return $db.UserSession().destroy({ 51 | where: { 52 | id: this.id, 53 | }, 54 | }); 55 | } 56 | } 57 | 58 | export default UserSession; 59 | -------------------------------------------------------------------------------- /frontend/src/mixins/Dates.vue: -------------------------------------------------------------------------------- 1 | 58 | -------------------------------------------------------------------------------- /frontend/src/mixins/Stats.vue: -------------------------------------------------------------------------------- 1 | 48 | -------------------------------------------------------------------------------- /src/routes/api/v1/Tokens.ts: -------------------------------------------------------------------------------- 1 | import { Optional } from '../../../types'; 2 | import { Request, Response } from 'express'; 3 | 4 | import ApiV1Route from './Route'; 5 | import { ValidationError } from '../../../errors'; 6 | import { AuthInfo, authenticate } from '../../../auth'; 7 | 8 | class Tokens extends ApiV1Route { 9 | constructor() { 10 | super('/tokens'); 11 | } 12 | 13 | /** 14 | * Create a new API token for the user. 15 | */ 16 | @authenticate() 17 | post = async (req: Request, res: Response, auth: Optional) => { 18 | const user = auth!.user; 19 | const expiresAt = req.body?.expiresAt; 20 | let expiresAtDate: Optional = null; 21 | 22 | if (expiresAt) { 23 | try { 24 | expiresAtDate = new Date(expiresAt); 25 | } catch (error) { 26 | throw new ValidationError(`Invalid expiresAt: ${error}`); 27 | } 28 | } 29 | 30 | const session = await $repos.userSessions.create(user.id, { 31 | name: req.body?.name, 32 | isApi: true, 33 | expiresAt: expiresAtDate, 34 | }) 35 | 36 | res.json({ 37 | token: session.getToken(), 38 | }); 39 | } 40 | 41 | /** 42 | * List all the API tokens for the user. 43 | */ 44 | @authenticate() 45 | get = async (_: Request, res: Response, auth: Optional) => { 46 | const user = auth!.user; 47 | const sessions = await $repos.userSessions.byUser(user.id, { isApi: true }); 48 | res.json({ tokens: sessions }); 49 | } 50 | } 51 | 52 | export default Tokens; 53 | -------------------------------------------------------------------------------- /src/App.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors'; 2 | import express from 'express'; 3 | import bodyParser from 'body-parser'; 4 | 5 | import { useGlobals } from './globals'; 6 | import Routes from './routes'; 7 | 8 | class App { 9 | private readonly app: express.Express; 10 | private readonly address: string; 11 | private readonly port: number; 12 | 13 | private constructor({ 14 | app, 15 | address, 16 | port, 17 | routes, 18 | }: any) { 19 | useGlobals(); 20 | $db.sync().then(() => { 21 | $repos.userRoles.init().then(() => { 22 | $repos.users.syncAdminUser().then(() => { 23 | console.log(' The database is ready'); 24 | }) 25 | }) 26 | }) 27 | 28 | this.app = app; 29 | this.address = address; 30 | this.port = port; 31 | 32 | app.use(cors()); 33 | app.use(bodyParser.json({ limit: '10mb' })); 34 | app.use(express.static('frontend/dist')); 35 | routes.register(app) 36 | } 37 | 38 | public static fromEnv(): App { 39 | const address = process.env.BACKEND_ADDRESS || '127.0.0.1'; 40 | const port = new Number(process.env.BACKEND_PORT || 3000).valueOf(); 41 | const app = express(); 42 | const routes = new Routes(); 43 | 44 | return new App({ 45 | app, 46 | address, 47 | port, 48 | routes, 49 | }); 50 | } 51 | 52 | public listen(): void { 53 | this.app.listen(this.port, this.address, () => { 54 | console.log(`Server is running on port ${this.address}:${this.port}`); 55 | }); 56 | } 57 | } 58 | 59 | export default App; 60 | -------------------------------------------------------------------------------- /frontend/src/elements/FloatingButton.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 37 | 38 | 72 | -------------------------------------------------------------------------------- /src/repos/Stats.ts: -------------------------------------------------------------------------------- 1 | import {LocationStats} from '../responses'; 2 | import {Sequelize} from 'sequelize'; 3 | import {StatsRequest} from '../requests'; 4 | 5 | class Stats { 6 | public async get(req: StatsRequest): Promise { 7 | const dbColumnsToProps = req.groupBy.reduce((acc, g) => { 8 | acc[$db.locationTableColumns[g]] = g; 9 | return acc; 10 | }, {} as Record); 11 | 12 | const groupBy = Object.keys(dbColumnsToProps); 13 | 14 | return ( 15 | await $db.GPSData().findAll({ 16 | attributes: [ 17 | ...groupBy, 18 | [Sequelize.fn('COUNT', Sequelize.col($db.locationTableColumns.id)), 'count'], 19 | [Sequelize.fn('MIN', Sequelize.col($db.locationTableColumns.timestamp)), 'startDate'], 20 | [Sequelize.fn('MAX', Sequelize.col($db.locationTableColumns.timestamp)), 'endDate'], 21 | ], 22 | where: { 23 | deviceId: (await $db.UserDevice() .findAll({where: {userId: req.userId}})) 24 | .map((d) => d.dataValues.id) 25 | }, 26 | group: groupBy, 27 | order: [[req.orderBy, req.order]], 28 | }) 29 | ).map(({dataValues: data}: any) => 30 | new LocationStats({ 31 | key: groupBy.reduce((acc, k) => { 32 | acc[dbColumnsToProps[k] || k] = data[k]; 33 | return acc; 34 | }, {} as Record), 35 | count: data.count, 36 | startDate: data.startDate, 37 | endDate: data.endDate, 38 | }) 39 | ); 40 | } 41 | } 42 | 43 | export default Stats; 44 | -------------------------------------------------------------------------------- /frontend/src/styles/layout.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | 3 | // Set screen width breakpoints 4 | $breakpoints: ( 5 | mobile : 0px, 6 | tablet : 680px, 7 | desktop: 960px 8 | ); 9 | 10 | // Mixin to print out media queries (based on map keys passed) 11 | @mixin media($keys...) { 12 | @each $key in $keys { 13 | @media (min-width: map.get($breakpoints, $key)) { 14 | @content 15 | } 16 | } 17 | } 18 | 19 | // Generate col-- classes 20 | $screen-size-to-breakpoint: ( 21 | s:mobile, 22 | m:tablet, 23 | l:desktop 24 | ); 25 | 26 | @for $i from 1 through 12 { 27 | @each $screen-size, $breakpoint in $screen-size-to-breakpoint { 28 | .col-#{$screen-size}-#{$i} { 29 | @media (min-width: map.get($breakpoints, $breakpoint)) { 30 | width: calc((100% / 12) * $i); 31 | } 32 | } 33 | } 34 | } 35 | 36 | .hidden { 37 | display: none !important; 38 | } 39 | 40 | @mixin from($key) { 41 | @media (min-width: map.get($breakpoints, $key)) { 42 | @content; 43 | } 44 | } 45 | 46 | @mixin until($key) { 47 | @media (max-width: #{map.get($breakpoints, $key) - 1}) { 48 | @content; 49 | } 50 | } 51 | 52 | .from.desktop { 53 | @include until(desktop) { 54 | display: none !important; 55 | } 56 | } 57 | 58 | .from.tablet { 59 | @include until(tablet) { 60 | display: none !important; 61 | } 62 | } 63 | 64 | .until.tablet { 65 | @include from(tablet) { 66 | display: none !important; 67 | } 68 | } 69 | 70 | .until.desktop { 71 | @include from(desktop) { 72 | display: none !important; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /frontend/src/elements/PopupMessage.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 36 | 37 | 77 | -------------------------------------------------------------------------------- /src/ext/location/NominatimLocationInfoProvider.ts: -------------------------------------------------------------------------------- 1 | import { GPSPoint } from "../../models"; 2 | 3 | class NominatimLocationInfoProvider { 4 | private apiUrl: string; 5 | private userAgent: string; 6 | 7 | constructor() { 8 | this.apiUrl = $geocode.nominatim.url; 9 | this.userAgent = $geocode.nominatim.userAgent; 10 | } 11 | 12 | async getLocationInfo(location: GPSPoint): Promise { 13 | const response = await fetch( 14 | `${this.apiUrl}/reverse?lat=${location.latitude}&lon=${location.longitude}&format=json`, 15 | { 16 | headers: { 17 | 'User-Agent': this.userAgent, 18 | }, 19 | } 20 | ); 21 | 22 | if (!response.ok) { 23 | throw new Error(`Error fetching location info: ${response.statusText}`); 24 | } 25 | 26 | const data = await response.json(); 27 | const address = data.address || {}; 28 | 29 | if (Object.keys(address).length > 0) { 30 | let addressString: string | undefined = ( 31 | (address.road || '') + ( 32 | address.house_number ? (' ' + address.house_number) : '' 33 | ) 34 | ).trim(); 35 | 36 | if (!addressString?.length) { 37 | addressString = undefined; 38 | } 39 | 40 | return new GPSPoint({ 41 | ...location, 42 | description: location.description || address.amenity, 43 | address: addressString, 44 | locality: address.city || address.town || address.village, 45 | postalCode: address.postcode, 46 | country: address.country_code, 47 | }) 48 | } 49 | 50 | return location; 51 | } 52 | } 53 | 54 | export default NominatimLocationInfoProvider; 55 | -------------------------------------------------------------------------------- /frontend/src/mixins/Routes.vue: -------------------------------------------------------------------------------- 1 | 58 | -------------------------------------------------------------------------------- /src/db/migrations/001_add_metadata_to_location_points.ts: -------------------------------------------------------------------------------- 1 | async function addLocationHistoryColumns(query: { context: any }) { 2 | const { DataTypes } = require('sequelize'); 3 | 4 | await query.context.addColumn($db.locationTable, $db.locationTableColumns['battery'], { 5 | type: DataTypes.FLOAT, 6 | allowNull: true 7 | }); 8 | 9 | await query.context.addColumn($db.locationTable, $db.locationTableColumns['speed'], { 10 | type: DataTypes.FLOAT, 11 | allowNull: true 12 | }); 13 | 14 | await query.context.addColumn($db.locationTable, $db.locationTableColumns['accuracy'], { 15 | type: DataTypes.FLOAT, 16 | allowNull: true 17 | }); 18 | } 19 | 20 | async function removeLocationHistoryColumns(query: { context: any }) { 21 | await query.context.removeColumn($db.locationTable, $db.locationTableColumns['battery']); 22 | await query.context.removeColumn($db.locationTable, $db.locationTableColumns['speed']); 23 | await query.context.removeColumn($db.locationTable, $db.locationTableColumns['accuracy']); 24 | } 25 | 26 | const addMetadataToLocationPoints = { 27 | up: async (query: { context: any }) => { 28 | try { 29 | await addLocationHistoryColumns({ context: query.context }); 30 | } catch (error) { 31 | console.warn('Error adding metadata columns to location points:', error); 32 | } 33 | }, 34 | 35 | down: async (query: { context: any }) => { 36 | if ($db.locationUrl !== $db.url) { 37 | console.log('The location history table is stored on an external database, skipping deletion'); 38 | return; 39 | } 40 | 41 | await removeLocationHistoryColumns({ context: query.context }); 42 | }, 43 | } 44 | 45 | module.exports = addMetadataToLocationPoints; 46 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gpstracker", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "run-p type-check \"build-only {@}\" --", 9 | "preview": "vite preview", 10 | "build-only": "vite build", 11 | "type-check": "vue-tsc --build", 12 | "lint": "eslint . --fix", 13 | "format": "prettier --write src/" 14 | }, 15 | "dependencies": { 16 | "@fortawesome/fontawesome-svg-core": "^6.7.2", 17 | "@fortawesome/free-brands-svg-icons": "^6.7.2", 18 | "@fortawesome/free-regular-svg-icons": "^6.7.2", 19 | "@fortawesome/free-solid-svg-icons": "^6.7.2", 20 | "@fortawesome/vue-fontawesome": "^3.0.8", 21 | "@vueuse/core": "^12.8.2", 22 | "chart.js": "^4.4.8", 23 | "chartjs-adapter-date-fns": "^3.0.0", 24 | "countries-list": "^3.1.1", 25 | "lodash": "^4.17.21", 26 | "mitt": "^3.0.1", 27 | "ol": "^10.4.0", 28 | "vue": "^3.5.13", 29 | "vue-chartjs": "^5.3.2", 30 | "vue-router": "^4.5.0" 31 | }, 32 | "devDependencies": { 33 | "@tsconfig/node22": "^22.0.0", 34 | "@types/lodash": "^4.17.15", 35 | "@types/node": "^22.13.4", 36 | "@vitejs/plugin-vue": "^5.2.1", 37 | "@vue/eslint-config-prettier": "^10.2.0", 38 | "@vue/eslint-config-typescript": "^14.4.0", 39 | "@vue/tsconfig": "^0.7.0", 40 | "eslint": "^9.20.1", 41 | "eslint-plugin-vue": "^9.32.0", 42 | "jiti": "^2.4.2", 43 | "npm-run-all2": "^7.0.2", 44 | "prettier": "^3.5.1", 45 | "sass-embedded": "^1.85.0", 46 | "typescript": "~5.7.3", 47 | "vite": "^6.2.6", 48 | "vite-plugin-pwa": "^1.0.0", 49 | "vite-plugin-vue-devtools": "^7.7.2", 50 | "vue-tsc": "^2.2.2" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /frontend/src/models/TimelineMetricsConfiguration.ts: -------------------------------------------------------------------------------- 1 | class TimelineMetricsConfiguration { 2 | public altitude: boolean = false; 3 | public distance: boolean = true; 4 | public speed: boolean = false; 5 | public battery: boolean = false; 6 | 7 | constructor(data: any | null = null) { 8 | if (!data) { 9 | return; 10 | } 11 | 12 | for (const key of ['altitude', 'distance', 'speed', 'battery']) { 13 | const value = String( 14 | data[key] ?? data['show' + key.charAt(0).toUpperCase() + key.slice(1)] 15 | ) 16 | 17 | switch (value) { 18 | case '1': 19 | case 'true': 20 | // @ts-expect-error 21 | this[key] = true; 22 | break; 23 | case '0': 24 | case 'false': 25 | // @ts-expect-error 26 | this[key] = false; 27 | break; 28 | } 29 | } 30 | } 31 | 32 | toggleMetric(metric: string) { 33 | switch (metric) { 34 | case 'altitude': 35 | this.altitude = !this.altitude; 36 | break; 37 | case 'distance': 38 | this.distance = !this.distance; 39 | break; 40 | case 'speed': 41 | this.speed = !this.speed; 42 | break; 43 | case 'battery': 44 | this.battery = !this.battery; 45 | break; 46 | default: 47 | throw new TypeError(`Invalid timeline metric: ${metric}`); 48 | } 49 | } 50 | 51 | toQuery(): Record { 52 | return ['altitude', 'distance', 'speed', 'battery'].reduce((acc: Record, key: string) => { 53 | acc['show' + key.charAt(0).toUpperCase() + key.slice(1)] = String((this as any)[key]); 54 | return acc; 55 | }, {}); 56 | } 57 | } 58 | 59 | export default TimelineMetricsConfiguration; 60 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build image 2 | FROM node:23-alpine AS build 3 | 4 | WORKDIR /app 5 | 6 | # Copy backend source files 7 | COPY LICENSE \ 8 | README.md \ 9 | package.json \ 10 | package-lock.json \ 11 | tsconfig.json \ 12 | Makefile \ 13 | ./ 14 | 15 | COPY src ./src 16 | 17 | # Copy frontend source files 18 | RUN mkdir -p ./frontend 19 | COPY frontend/package.json \ 20 | frontend/package-lock.json \ 21 | frontend/tsconfig.json \ 22 | frontend/tsconfig.app.json \ 23 | frontend/tsconfig.node.json \ 24 | frontend/vite.config.ts \ 25 | frontend/env.d.ts \ 26 | frontend/index.html \ 27 | frontend/Makefile \ 28 | ./frontend/ 29 | 30 | COPY frontend/src ./frontend/src 31 | COPY frontend/public ./frontend/public 32 | 33 | # Install system dependencies 34 | RUN apk add --no-cache typescript make 35 | 36 | # Build all 37 | RUN make 38 | 39 | # Remove dev dependencies 40 | RUN npm prune --production 41 | RUN cd frontend && npm prune --production 42 | RUN apk del make typescript 43 | 44 | # Web image 45 | FROM node:23-alpine AS web 46 | 47 | WORKDIR /app 48 | 49 | # Copy built files 50 | COPY --from=build \ 51 | /app/package.json \ 52 | /app/LICENSE \ 53 | /app/README.md \ 54 | ./ 55 | 56 | COPY --from=build /app/dist ./dist 57 | COPY --from=build /app/node_modules ./node_modules 58 | 59 | # Copy frontend built files 60 | COPY --from=build /app/frontend/dist ./frontend/dist 61 | COPY --from=build /app/frontend/node_modules ./frontend/node_modules 62 | COPY --from=build /app/frontend/public ./frontend/public 63 | COPY --from=build \ 64 | /app/frontend/package.json \ 65 | /app/frontend/package-lock.json \ 66 | /app/frontend/index.html \ 67 | /app/frontend/ 68 | 69 | # Run the app 70 | CMD ["npm", "run", "start"] 71 | -------------------------------------------------------------------------------- /frontend/src/models/GPSPoint.ts: -------------------------------------------------------------------------------- 1 | import type { Optional } from "./Types"; 2 | 3 | class GPSPoint { 4 | public id: number; 5 | public latitude: number; 6 | public longitude: number; 7 | public altitude?: Optional; 8 | public deviceId: string; 9 | public address?: Optional; 10 | public locality?: Optional; 11 | public country?: Optional; 12 | public postalCode?: Optional; 13 | public description?: Optional; 14 | public battery?: Optional; 15 | public speed?: Optional; 16 | public accuracy?: Optional; 17 | public timestamp: Date; 18 | 19 | constructor(data: { 20 | id: number; 21 | latitude: number; 22 | longitude: number; 23 | altitude?: number; 24 | deviceId: string; 25 | address?: string; 26 | locality?: string; 27 | country?: string; 28 | postalCode?: string; 29 | description?: string; 30 | battery?: number; 31 | speed?: number; 32 | accuracy?: number; 33 | timestamp?: Date; 34 | }) { 35 | this.id = data.id; 36 | this.latitude = data.latitude; 37 | this.longitude = data.longitude; 38 | this.altitude = data.altitude; 39 | this.deviceId = data.deviceId; 40 | this.address = data.address; 41 | this.locality = data.locality; 42 | this.country = data.country; 43 | this.postalCode = data.postalCode; 44 | this.description = data.description; 45 | this.battery = data.battery; 46 | this.speed = data.speed; 47 | this.accuracy = data.accuracy; 48 | this.timestamp = data.timestamp || new Date(); 49 | } 50 | 51 | public static fromLatLng({ 52 | latitude, 53 | longitude, 54 | }: { 55 | latitude: number; 56 | longitude: number; 57 | }) { 58 | return new GPSPoint({ 59 | id: 0, 60 | latitude, 61 | longitude, 62 | deviceId: '', 63 | }); 64 | } 65 | } 66 | 67 | export default GPSPoint; 68 | -------------------------------------------------------------------------------- /frontend/src/elements/Dropdown.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 34 | 35 | 92 | -------------------------------------------------------------------------------- /frontend/src/mixins/api/Common.vue: -------------------------------------------------------------------------------- 1 | 70 | -------------------------------------------------------------------------------- /frontend/src/components/stats/MetricsForm.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 49 | 50 | 89 | -------------------------------------------------------------------------------- /frontend/src/elements/Modal.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 37 | 38 | 99 | -------------------------------------------------------------------------------- /src/config/Geocode.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../errors'; 2 | 3 | type GeocodeProvider = 'google' | 'nominatim'; 4 | 5 | class NominatimConfig { 6 | public readonly url: string; 7 | public readonly userAgent: string; 8 | 9 | private constructor(args: { url: string; userAgent: string }) { 10 | this.url = args.url; 11 | this.userAgent = args.userAgent; 12 | } 13 | 14 | public static fromEnv(): NominatimConfig { 15 | return new NominatimConfig({ 16 | url: process.env.NOMINATIM_URL || 'https://nominatim.openstreetmap.org', 17 | userAgent: process.env.NOMINATIM_USER_AGENT || 'Mozilla/5.0 (compatible; gpstracker/1.0; +https://github.com/blacklight/gpstracker)', 18 | }); 19 | } 20 | } 21 | 22 | class GoogleConfig { 23 | public readonly url: string; 24 | public readonly apiKey: string; 25 | 26 | private constructor(args: { apiKey: string }) { 27 | this.url = 'https://maps.googleapis.com/maps/api/geocode/json' 28 | this.apiKey = args.apiKey; 29 | } 30 | 31 | public static fromEnv(): GoogleConfig { 32 | return new GoogleConfig({ 33 | apiKey: process.env.GOOGLE_API_KEY || '', 34 | }); 35 | } 36 | } 37 | 38 | class Geocode { 39 | public readonly provider?: GeocodeProvider; 40 | public readonly nominatim: NominatimConfig; 41 | public readonly google: GoogleConfig; 42 | 43 | private constructor(args: { 44 | provider?: GeocodeProvider; 45 | nominatim: NominatimConfig; 46 | google: GoogleConfig; 47 | }) { 48 | this.provider = args.provider; 49 | this.nominatim = args.nominatim; 50 | this.google = args.google; 51 | 52 | if (this.provider === 'google' && !this.google.apiKey) { 53 | throw new ValidationError('Google API key is required when using Google geocoding.'); 54 | } 55 | } 56 | 57 | public static fromEnv(): Geocode { 58 | const provider = process.env.GEOCODE_PROVIDER as GeocodeProvider | undefined; 59 | if (provider?.length && provider !== 'google' && provider !== 'nominatim') { 60 | throw new ValidationError('GEOCODE_PROVIDER must be either "google" or "nominatim".'); 61 | } 62 | 63 | return new Geocode({ 64 | provider, 65 | nominatim: NominatimConfig.fromEnv(), 66 | google: GoogleConfig.fromEnv(), 67 | }); 68 | } 69 | } 70 | 71 | export default Geocode; 72 | -------------------------------------------------------------------------------- /src/repos/UserSessions.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | 3 | import { Optional } from '~/types'; 4 | import { Unauthorized } from '../errors'; 5 | import { UserSession } from '../models'; 6 | 7 | class UserSessions { 8 | public async find(sessionId: string): Promise> { 9 | const dbSession = await $db.UserSession().findByPk(sessionId) 10 | if (!dbSession) { 11 | return null; 12 | } 13 | 14 | const session = new UserSession(dbSession.dataValues); 15 | if (session.expiresAt && session.expiresAt < new Date()) { 16 | await session.destroy(); 17 | return null; 18 | } 19 | 20 | return session; 21 | } 22 | 23 | public async create(userId: number, args: { 24 | expiresAt?: Optional, 25 | name?: Optional, 26 | isApi?: Optional, 27 | }): Promise { 28 | const session = await $db.UserSession().create({ 29 | userId, 30 | name: args.name, 31 | isApi: args.isApi || false, 32 | expiresAt: args.expiresAt ? new Date(args.expiresAt).toISOString() : null, 33 | }); 34 | 35 | return new UserSession(session.dataValues); 36 | } 37 | 38 | public async byToken(token: string): Promise> { 39 | let payload: Record 40 | 41 | try { 42 | payload = jwt.verify(token, $secrets.serverKey) as { 43 | sessionId: string, userId: number 44 | }; 45 | } catch (error) { 46 | throw new Unauthorized('Invalid token'); 47 | } 48 | 49 | const session = await this.find(payload.sessionId); 50 | let expiresAt = session?.expiresAt; 51 | 52 | if ( 53 | !session || 54 | session.userId !== payload.userId || 55 | (expiresAt && expiresAt < new Date()) 56 | ) { 57 | throw new Unauthorized('Invalid token'); 58 | } 59 | 60 | return session; 61 | } 62 | 63 | public async byUser(userId: number, { isApi }: { isApi?: boolean } = {}): Promise { 64 | const filter = { userId } as { userId: number, isApi?: boolean }; 65 | if (isApi != null) { 66 | filter['isApi'] = !!isApi; 67 | } 68 | 69 | return ( 70 | await $db.UserSession().findAll({ 71 | where: filter, 72 | }) 73 | ).map((session: any) => new UserSession(session.dataValues)); 74 | } 75 | } 76 | 77 | export default UserSessions; 78 | -------------------------------------------------------------------------------- /frontend/src/views/API.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 90 | 91 | 94 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | 3 | import { defineConfig, loadEnv } from 'vite' 4 | import { VitePWA } from 'vite-plugin-pwa' 5 | import vue from '@vitejs/plugin-vue' 6 | import vueDevTools from 'vite-plugin-vue-devtools' 7 | 8 | // https://vite.dev/config/ 9 | export default defineConfig((env) => { 10 | const envDir = '../'; 11 | const envars = loadEnv(env.mode, envDir); 12 | const serverURL = new URL( 13 | envars.VITE_API_SERVER_URL ?? 'http://localhost:3000' 14 | ); 15 | const serverAPIPath = envars.VITE_API_PATH ?? '/api/v1'; 16 | 17 | return { 18 | envDir: envDir, 19 | 20 | // make the API path globally available in the client 21 | define: { 22 | __API_PATH__: JSON.stringify(serverAPIPath), 23 | }, 24 | 25 | plugins: [ 26 | vue(), 27 | vueDevTools(), 28 | VitePWA({ 29 | injectRegister: 'auto', 30 | includeAssets: [ 31 | 'favicon.ico', 32 | 'icons/*', 33 | ], 34 | manifest: { 35 | name: "GPSTracker", 36 | short_name: "GPSTracker", 37 | theme_color: "#3498db", 38 | icons: [ 39 | { 40 | src: "./icons/pwa-192x192.png", 41 | sizes: "192x192", 42 | type: "image/png" 43 | }, 44 | { 45 | src: "./icons/pwa-512x512.png", 46 | sizes: "512x512", 47 | type: "image/png" 48 | }, 49 | { 50 | src: "./icons/pwa-maskable-192x192.png", 51 | sizes: "192x192", 52 | type: "image/png", 53 | purpose: "maskable" 54 | }, 55 | { 56 | src: "./icons/pwa-maskable-512x512.png", 57 | sizes: "512x512", 58 | type: "image/png", 59 | purpose: "maskable" 60 | } 61 | ], 62 | start_url: ".", 63 | display: "standalone" 64 | }, 65 | }), 66 | ], 67 | 68 | resolve: { 69 | alias: { 70 | '@': fileURLToPath(new URL('./src', import.meta.url)) 71 | }, 72 | }, 73 | 74 | server: { 75 | port: 5173, 76 | proxy: { 77 | // Proxy requests with the API path to the server 78 | // -> 79 | [serverAPIPath]: serverURL.origin, 80 | // Proxy requests to /icons/poi.svg to the server 81 | '/icons/poi.svg': `${serverURL.origin}`, 82 | }, 83 | }, 84 | } 85 | }) 86 | -------------------------------------------------------------------------------- /src/routes/Icons.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import Route from './Route'; 4 | import Routes from './Routes'; 5 | 6 | const circleSVGTemplate = ` 7 | 8 | ` 9 | 10 | const poiSVGTemplate = ` 11 | 30 | ` 31 | 32 | class PoiIconRoute extends Route { 33 | protected version: string; 34 | 35 | constructor() { 36 | super('/icons/poi.svg'); 37 | } 38 | 39 | get = async (req: Request, res: Response) => { 40 | const { color, size, classes, border, fill } = req.query; 41 | let classesArray = new Set(classes?.toString().split(' ') || []); 42 | 43 | if (border) { 44 | classesArray.add('with-border'); 45 | } 46 | 47 | if (fill) { 48 | classesArray.add('with-fill'); 49 | } 50 | 51 | const svg = poiSVGTemplate 52 | .replaceAll('{{COLOR}}', color?.toString() || '000000') 53 | .replaceAll('{{SIZE}}', size?.toString() || '100') 54 | .replaceAll('{{CLASS}}', classesArray.size ? Array.from(classesArray).join(' ') : '') 55 | .replaceAll('{{BORDER}}', border?.toString() || '000000') 56 | .replaceAll('{{FILL}}', fill?.toString() || '000000') 57 | .replaceAll('{{CIRCLE}}', fill ? circleSVGTemplate : ''); 58 | 59 | res.setHeader('Content-Type', 'image/svg+xml'); 60 | res.send(svg); 61 | } 62 | } 63 | 64 | class IconRoutes extends Routes { 65 | public routes = [new PoiIconRoute()]; 66 | } 67 | 68 | export default IconRoutes; 69 | -------------------------------------------------------------------------------- /src/routes/api/v1/Auth.ts: -------------------------------------------------------------------------------- 1 | import { Optional } from '../../../types'; 2 | import { Request, Response } from 'express'; 3 | 4 | import ApiV1Route from './Route'; 5 | import { ValidationError } from '../../../errors'; 6 | import { AuthInfo, authenticate } from '../../../auth'; 7 | import { clearCookie, setCookie } from '../../../helpers/cookies'; 8 | 9 | class Auth extends ApiV1Route { 10 | constructor() { 11 | super('/auth'); 12 | } 13 | 14 | /** 15 | * Create a new session for the user. 16 | * 17 | * If the user is already authenticated (either through a cookie or a token), 18 | * the existing session will be returned. Otherwise, a new session will be created. 19 | * 20 | * @param req: Request - The request object. 21 | * @param res: Response - The response object. 22 | * @param auth: Optional - The authentication information. 23 | */ 24 | @authenticate() 25 | post = async (req: Request, res: Response, auth: Optional) => { 26 | const user = auth!.user; 27 | let session = auth!.session; 28 | const expiresAt = req.body?.expiresAt; 29 | let expiresAtDate: Optional = null; 30 | 31 | if (session) { 32 | console.debug('The user already has an active session or token'); 33 | res.json({ 34 | session: { 35 | token: session.getToken(), 36 | } 37 | }); 38 | 39 | return; 40 | } 41 | 42 | if (expiresAt) { 43 | try { 44 | expiresAtDate = new Date(expiresAt); 45 | } catch (error) { 46 | throw new ValidationError(`Invalid expiresAt: ${error}`); 47 | } 48 | } 49 | 50 | session = await $repos.userSessions.create(user.id, { 51 | name: req.body?.name, 52 | expiresAt: expiresAtDate, 53 | }) 54 | 55 | setCookie(res, { 56 | name: 'session', 57 | value: session.getToken(), 58 | expiresAt: expiresAtDate, 59 | }); 60 | 61 | res.json({ 62 | token: session.getToken(), 63 | }); 64 | } 65 | 66 | /** 67 | * Delete the current session. 68 | * 69 | * It requires the user to be authenticated (either through a cookie or a token). 70 | * If the user is authenticated, the session will be destroyed and the cookie will be cleared. 71 | */ 72 | @authenticate() 73 | delete = async (_: Request, res: Response, auth?: Optional) => { 74 | const session = auth!.session; 75 | 76 | if (session) { 77 | await session.destroy(); 78 | } 79 | 80 | clearCookie(res, 'session'); 81 | res.status(204).send(); 82 | } 83 | } 84 | 85 | export default Auth; 86 | -------------------------------------------------------------------------------- /frontend/src/components/TimelineOptions.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 59 | 60 | 90 | -------------------------------------------------------------------------------- /src/routes/api/v1/DevicesById.ts: -------------------------------------------------------------------------------- 1 | import { Optional } from '../../../types'; 2 | import { Request, Response } from 'express'; 3 | 4 | import ApiV1Route from './Route'; 5 | import { AuthInfo, authenticate } from '../../../auth'; 6 | import { Forbidden, Unauthorized } from '../../../errors'; 7 | import { RoleName, UserDevice } from '../../../models'; 8 | 9 | class DevicesById extends ApiV1Route { 10 | constructor() { 11 | super('/devices/:deviceId'); 12 | } 13 | 14 | private async authenticatedFetch(auth: AuthInfo, deviceId: string): Promise { 15 | const device = await $repos.userDevices.get(deviceId); 16 | 17 | if (!device) { 18 | // Throw a 403 instead of a 404 to avoid leaking information 19 | throw new Forbidden('You do not have access to this device'); 20 | } 21 | 22 | if (device.userId !== auth!.user.id) { 23 | try { 24 | authenticate([RoleName.Admin]); 25 | } catch (e) { 26 | throw new Unauthorized('You do not have access to this device'); 27 | } 28 | } 29 | 30 | return device; 31 | } 32 | 33 | /** 34 | * GET /devices/:deviceId 35 | * 36 | * It returns a JSON object with the requested device. 37 | * Note that the device must be associated with the authenticated user, 38 | * unless the user is an admin. 39 | */ 40 | @authenticate() 41 | get = async (req: Request, res: Response, auth: Optional) => { 42 | res.json(await this.authenticatedFetch(auth!, req.params.deviceId)); 43 | } 44 | 45 | /** 46 | * PATCH /devices/:deviceId 47 | * 48 | * It updates the requested device. 49 | * Note that the device must be associated with the authenticated user, 50 | * unless the user is an admin. 51 | */ 52 | @authenticate() 53 | patch = async (req: Request, res: Response, auth: Optional) => { 54 | const device = await this.authenticatedFetch(auth!, req.params.deviceId); 55 | const { name } = req.body; 56 | if (name) { 57 | device.name = name; 58 | } 59 | 60 | await device.save(); 61 | res.json(device); 62 | } 63 | 64 | /** 65 | * DELETE /devices/:deviceId 66 | * 67 | * It deletes the requested device. 68 | * Note that the device must be associated with the authenticated user, 69 | * unless the user is an admin. 70 | */ 71 | @authenticate() 72 | delete = async (req: Request, res: Response, auth: Optional) => { 73 | const device = await this.authenticatedFetch(auth!, req.params.deviceId); 74 | await device.destroy(); 75 | res.status(204).send(); 76 | } 77 | } 78 | 79 | export default DevicesById; 80 | -------------------------------------------------------------------------------- /frontend/src/models/LocationQuery.ts: -------------------------------------------------------------------------------- 1 | import { type Optional } from "./Types"; 2 | 3 | class LocationQuery { 4 | public limit: number = 500; 5 | public offset: Optional = null; 6 | public deviceId: Optional = null; 7 | public startDate: Optional = null; 8 | public endDate: Optional = null; 9 | public ids: Optional = null; 10 | public minId: Optional = null; 11 | public maxId: Optional = null; 12 | public minLatitude: Optional = null; 13 | public maxLatitude: Optional = null; 14 | public minLongitude: Optional = null; 15 | public maxLongitude: Optional = null; 16 | public country: Optional = null; 17 | public locality: Optional = null; 18 | public postalCode: Optional = null; 19 | public address: Optional = null; 20 | public description: Optional = null; 21 | public order: string = 'desc'; 22 | 23 | constructor(data: { 24 | limit?: Optional; 25 | offset?: Optional; 26 | deviceId?: Optional; 27 | startDate?: Optional; 28 | endDate?: Optional; 29 | ids?: Optional; 30 | minId?: Optional; 31 | maxId?: Optional; 32 | minLatitude?: Optional; 33 | maxLatitude?: Optional; 34 | minLongitude?: Optional; 35 | maxLongitude?: Optional; 36 | country?: Optional; 37 | locality?: Optional; 38 | postalCode?: Optional; 39 | address?: Optional; 40 | description?: Optional; 41 | order?: Optional; 42 | }) { 43 | this.limit = data.limit || this.limit; 44 | this.offset = data.offset || this.offset; 45 | this.deviceId = data.deviceId || this.deviceId; 46 | this.startDate = data.startDate || this.startDate; 47 | this.endDate = data.endDate || this.endDate; 48 | this.ids = data.ids ? (Array.isArray(data.ids) ? data.ids : data.ids.split(/\s*,\s*/).map(Number)) : this.ids; 49 | this.minId = data.minId || this.minId; 50 | this.maxId = data.maxId || this.maxId; 51 | this.minLatitude = data.minLatitude || this.minLatitude; 52 | this.maxLatitude = data.maxLatitude || this.maxLatitude; 53 | this.minLongitude = data.minLongitude || this.minLongitude; 54 | this.maxLongitude = data.maxLongitude || this.maxLongitude; 55 | this.country = data.country || this.country; 56 | this.locality = data.locality || this.locality; 57 | this.postalCode = data.postalCode || this.postalCode; 58 | this.address = data.address || this.address; 59 | this.description = data.description || this.description; 60 | this.order = data.order || this.order; 61 | } 62 | } 63 | 64 | export default LocationQuery; 65 | -------------------------------------------------------------------------------- /frontend/src/components/Messages.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 85 | 86 | 103 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | 3 | import { Forbidden, Unauthorized } from './errors'; 4 | import { Optional } from '~/types'; 5 | import { RoleName, User, UserSession } from '~/models'; 6 | import Route from './routes/Route'; 7 | 8 | class AuthInfo { 9 | public user: User; 10 | public session: Optional; 11 | 12 | constructor(user: User, session: Optional = null) { 13 | this.user = user; 14 | this.session = session; 15 | } 16 | } 17 | 18 | function authenticate(roles: RoleName[] = []) { 19 | return function (route: any, method: string) { 20 | const routeClass = ( route.constructor); 21 | if (!routeClass.preRequestHandlers[routeClass.name]) { 22 | routeClass.preRequestHandlers[routeClass.name] = {} 23 | } 24 | 25 | routeClass.preRequestHandlers[routeClass.name][method] = async (req: Request): Promise => { 26 | let user: Optional; 27 | let session: Optional; 28 | 29 | // Check the `session` cookie or the `Authorization` header for the session token 30 | let token = req.headers?.cookie?.match(/session=([^;]+)/)?.[1]; 31 | if (!token?.length) { 32 | const authHeader = req.headers?.authorization; 33 | if (authHeader?.startsWith('Bearer ')) { 34 | token = authHeader.slice(7); 35 | } 36 | } 37 | 38 | // Check if the token is valid 39 | if (token?.length) { 40 | session = await $repos.userSessions.byToken(token); 41 | if (session) { 42 | user = await $repos.users.get(session.userId); 43 | } 44 | } 45 | 46 | if (!(session && user)) { 47 | // Check the `username` and `password` query or body parameters 48 | const username = req.body?.username || req.query?.username; 49 | const password = req.body?.password || req.query?.password; 50 | 51 | if (username?.length && password?.length) { 52 | user = await $repos.users.find(username); 53 | if (!(user && user.checkPassword(password))) { 54 | user = null; 55 | } 56 | } 57 | } 58 | 59 | if (!user) { 60 | throw new Unauthorized('Invalid credentials'); 61 | } 62 | 63 | if (roles.length) { 64 | // Check if the user has the required roles 65 | const userRoles = new Set((await user.roles()).map((role) => role.name)); 66 | const missingPermissions = roles.filter((role) => !userRoles.has(role)); 67 | 68 | if (missingPermissions.length) { 69 | throw new Forbidden('Missing required roles: [' + missingPermissions.join(', ') + ']'); 70 | } 71 | } 72 | 73 | return new AuthInfo(user, session); 74 | } 75 | } 76 | } 77 | 78 | export { AuthInfo, authenticate }; 79 | -------------------------------------------------------------------------------- /frontend/src/views/Devices.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 100 | 101 | 104 | -------------------------------------------------------------------------------- /frontend/src/mixins/URLQueryHandler.vue: -------------------------------------------------------------------------------- 1 | 91 | -------------------------------------------------------------------------------- /frontend/src/styles/elements.scss: -------------------------------------------------------------------------------- 1 | $table-header-height: 2rem; 2 | 3 | // Common element styles 4 | button { 5 | margin: 0 0.5em; 6 | padding: 0.25em 0.5em; 7 | border: 1px solid var(--color-border); 8 | border-radius: 0.25em; 9 | background: var(--color-background); 10 | color: var(--color-text); 11 | font-size: 1.25em; 12 | cursor: pointer; 13 | 14 | &:disabled { 15 | color: var(--color-text); 16 | opacity: 0.5; 17 | cursor: not-allowed; 18 | } 19 | 20 | &:hover { 21 | color: var(--color-hover); 22 | } 23 | } 24 | 25 | [type=submit] { 26 | background: var(--color-accent); 27 | color: white !important; 28 | margin: 0.5em; 29 | font-size: 1.15em; 30 | padding: 0.5em; 31 | transition: background-color 0.5s; 32 | 33 | &:hover { 34 | background: var(--color-hover); 35 | color: var(--color-background); 36 | } 37 | } 38 | 39 | [type=text], 40 | [type=email], 41 | [type=password], 42 | [type=number], 43 | [type=date], 44 | [type=datetime-local], 45 | select, 46 | textarea { 47 | width: 100%; 48 | background: var(--color-background-soft); 49 | color: var(--color-text); 50 | padding: 0.5em; 51 | border: 1px solid var(--color-border); 52 | border-radius: 4px; 53 | transition: border-color 0.5s; 54 | transition: border-color 0.5s; 55 | // 56 | width: 100%; 57 | padding: 0.5em; 58 | border: 1px solid var(--color-border); 59 | border-radius: 4px; 60 | background: var(--color-background-soft); 61 | } 62 | 63 | .loading-container { 64 | position: absolute; 65 | top: 0; 66 | left: 0; 67 | width: 100%; 68 | height: 100%; 69 | background-color: rgba(255, 255, 255, 0.5); 70 | display: flex; 71 | justify-content: center; 72 | align-items: center; 73 | z-index: 1; 74 | } 75 | 76 | form { 77 | .buttons { 78 | display: flex; 79 | align-items: center; 80 | justify-content: flex-end; 81 | margin-top: 0.33em; 82 | padding-top: 0.33em; 83 | 84 | button { 85 | height: 2.5em; 86 | } 87 | } 88 | } 89 | 90 | table { 91 | width: 100%; 92 | height: 100%; 93 | border-collapse: collapse; 94 | 95 | thead { 96 | height: $table-header-height; 97 | 98 | tr { 99 | position: sticky; 100 | top: 0; 101 | background-color: var(--color-background); 102 | z-index: 1; 103 | 104 | th { 105 | padding: 0.5rem; 106 | text-align: left; 107 | font-weight: bold; 108 | } 109 | } 110 | } 111 | 112 | tbody { 113 | height: calc(100% - #{$table-header-height}); 114 | 115 | tr { 116 | td { 117 | padding: 0.5rem; 118 | border-bottom: 1px solid var(--color-border); 119 | } 120 | 121 | &:hover { 122 | background-color: var(--color-background-soft); 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /frontend/src/components/devices/DeviceForm.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 105 | 106 | 125 | -------------------------------------------------------------------------------- /src/db/types/GPSData.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes } from 'sequelize'; 2 | 3 | function GPSData(locationTableColumns: Record): Record { 4 | const typeDef: Record = {}; 5 | 6 | typeDef[locationTableColumns['id']] = { 7 | type: DataTypes.INTEGER, 8 | primaryKey: true, 9 | autoIncrement: true 10 | }; 11 | 12 | const deviceIdCol: string = locationTableColumns['deviceId']; 13 | if (deviceIdCol?.length) { 14 | typeDef[deviceIdCol] = { 15 | type: DataTypes.UUID, 16 | }; 17 | } 18 | 19 | typeDef[locationTableColumns['latitude']] = { 20 | type: DataTypes.FLOAT, 21 | allowNull: false 22 | }; 23 | 24 | typeDef[locationTableColumns['longitude']] = { 25 | type: DataTypes.FLOAT, 26 | allowNull: false 27 | }; 28 | 29 | const altitudeCol: string = locationTableColumns['altitude']; 30 | if (altitudeCol?.length) { 31 | typeDef[altitudeCol] = { 32 | type: DataTypes.FLOAT, 33 | allowNull: true 34 | }; 35 | } 36 | 37 | const addressCol: string = locationTableColumns['address']; 38 | if (addressCol?.length) { 39 | typeDef[addressCol] = { 40 | type: DataTypes.STRING, 41 | allowNull: true 42 | }; 43 | } 44 | 45 | const localityCol: string = locationTableColumns['locality']; 46 | if (localityCol?.length) { 47 | typeDef[localityCol] = { 48 | type: DataTypes.STRING, 49 | allowNull: true 50 | }; 51 | } 52 | 53 | const countryCol: string = locationTableColumns['country']; 54 | if (countryCol?.length) { 55 | typeDef[countryCol] = { 56 | type: DataTypes.STRING, 57 | allowNull: true 58 | }; 59 | } 60 | 61 | const postalCodeCol: string = locationTableColumns['postalCode']; 62 | if (postalCodeCol?.length) { 63 | typeDef[postalCodeCol] = { 64 | type: DataTypes.STRING, 65 | allowNull: true 66 | }; 67 | } 68 | 69 | const descriptionCol: string = locationTableColumns['description']; 70 | if (descriptionCol?.length) { 71 | typeDef[descriptionCol] = { 72 | type: DataTypes.STRING, 73 | allowNull: true 74 | }; 75 | } 76 | 77 | const batteryCol: string = locationTableColumns['battery']; 78 | if (batteryCol?.length) { 79 | typeDef[batteryCol] = { 80 | type: DataTypes.FLOAT, 81 | allowNull: true 82 | }; 83 | } 84 | 85 | const speedCol: string = locationTableColumns['speed']; 86 | if (speedCol?.length) { 87 | typeDef[speedCol] = { 88 | type: DataTypes.FLOAT, 89 | allowNull: true 90 | }; 91 | } 92 | 93 | const accuracyCol: string = locationTableColumns['accuracy']; 94 | if (accuracyCol?.length) { 95 | typeDef[accuracyCol] = { 96 | type: DataTypes.FLOAT, 97 | allowNull: true 98 | }; 99 | } 100 | 101 | typeDef[locationTableColumns['timestamp']] = { 102 | type: DataTypes.DATE, 103 | defaultValue: DataTypes.NOW 104 | }; 105 | 106 | return typeDef; 107 | } 108 | 109 | export default GPSData; 110 | -------------------------------------------------------------------------------- /frontend/src/components/MapCircle.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 119 | 120 | 131 | -------------------------------------------------------------------------------- /src/ext/location/GoogleLocationInfoProvider.ts: -------------------------------------------------------------------------------- 1 | import { GPSPoint } from "../../models"; 2 | 3 | class GoogleLocationInfoProvider { 4 | private apiKey: string; 5 | private apiUrl: string; 6 | 7 | constructor() { 8 | this.apiKey = $geocode.google.apiKey 9 | this.apiUrl = $geocode.google.url 10 | } 11 | 12 | private parseAddressComponents(response: { 13 | results: { 14 | address_components: { 15 | long_name: string; 16 | short_name: string; 17 | types: string[]; 18 | }[]; 19 | }[] 20 | }): { 21 | address?: string; 22 | locality?: string; 23 | postalCode?: string; 24 | country?: string; 25 | description?: string; 26 | } { 27 | const result = { 28 | address: undefined, 29 | locality: undefined, 30 | postalCode: undefined, 31 | country: undefined, 32 | description: undefined, 33 | } as { 34 | address?: string; 35 | locality?: string; 36 | postalCode?: string; 37 | country?: string; 38 | description?: string; 39 | }; 40 | 41 | if (!response.results?.length) { 42 | return result; 43 | } 44 | 45 | const addressComponents = response.results[0].address_components.reduce( 46 | (acc: any, component: any) => { 47 | ['street_number', 'route', 'locality', 'postal_code'].forEach((type) => { 48 | if (component.types.includes(type)) { 49 | acc[type] = component.long_name; 50 | } 51 | }); 52 | 53 | if (component.types.includes('country')) { 54 | acc.country = component.short_name.toLowerCase(); 55 | } 56 | 57 | return acc; 58 | }, 59 | {}, 60 | ); 61 | 62 | if (addressComponents.route) { 63 | result.address = ( 64 | (addressComponents.route || '') + 65 | (addressComponents.street_number ? ' ' + addressComponents.street_number : '') 66 | ).trim(); 67 | 68 | if (!result.address?.length) { 69 | result.address = undefined; 70 | } 71 | } 72 | 73 | ['locality', 'postal_code', 'country'].forEach((key) => { 74 | if (addressComponents[key]) { 75 | // @ts-expect-error 76 | result[key] = addressComponents[key]; 77 | } 78 | }); 79 | 80 | return result; 81 | } 82 | 83 | async getLocationInfo(location: GPSPoint): Promise { 84 | const response = await fetch( 85 | `${this.apiUrl}?latlng=${location.latitude},${location.longitude}&key=${this.apiKey}`, 86 | ); 87 | 88 | if (!response.ok) { 89 | throw new Error(`Error fetching location info: ${response.statusText}`); 90 | } 91 | 92 | const data = await response.json(); 93 | const addressComponents = this.parseAddressComponents(data); 94 | 95 | return new GPSPoint({ 96 | ...location, 97 | address: location.address || addressComponents.address, 98 | locality: location.locality || addressComponents.locality, 99 | postalCode: location.postalCode || addressComponents.postalCode, 100 | country: location.country || addressComponents.country, 101 | }) 102 | } 103 | } 104 | 105 | export default GoogleLocationInfoProvider; 106 | -------------------------------------------------------------------------------- /frontend/src/elements/CountrySelector.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 134 | 135 | 142 | -------------------------------------------------------------------------------- /src/repos/Users.ts: -------------------------------------------------------------------------------- 1 | import { Op } from 'sequelize'; 2 | 3 | import { Optional } from '~/types'; 4 | import { RoleName } from '../models'; 5 | import { User } from '../models'; 6 | import { ValidationError } from '../errors'; 7 | 8 | class Users { 9 | public async syncAdminUser(): Promise { 10 | const adminPassword = $secrets.adminPassword; 11 | let adminUser = await this.find('admin'); 12 | const adminEmail = $secrets.adminEmail; 13 | 14 | if (adminUser) { 15 | let changed = false; 16 | 17 | if (!adminUser.checkPassword(adminPassword)) { 18 | adminUser.password = adminPassword; 19 | changed = true; 20 | } 21 | 22 | if (adminUser.email !== adminEmail) { 23 | adminUser.email = adminEmail; 24 | changed = true; 25 | } 26 | 27 | if (!changed) { 28 | return adminUser; 29 | } 30 | 31 | await adminUser.save(); 32 | } else { 33 | console.log('Creating admin user'); 34 | adminUser = await this.create({ 35 | username: 'admin', 36 | email: adminEmail, 37 | password: adminPassword, 38 | firstName: 'Admin', 39 | lastName: 'User', 40 | }); 41 | 42 | console.log('Admin user created'); 43 | } 44 | 45 | await adminUser.setRoles([RoleName.Admin]); 46 | return adminUser; 47 | } 48 | 49 | public async get(userId: number): Promise> { 50 | const dbUser = await $db.User().findByPk(userId); 51 | 52 | if (!dbUser) { 53 | return null; 54 | } 55 | 56 | return new User(dbUser.dataValues); 57 | } 58 | 59 | public async find(username: string): Promise> { 60 | const dbUser = await $db.User().findOne({ 61 | where: { 62 | [Op.or]: { 63 | username, 64 | email: username, 65 | }, 66 | }, 67 | }); 68 | 69 | if (!dbUser) { 70 | return null; 71 | } 72 | 73 | return new User(dbUser.dataValues); 74 | } 75 | 76 | public async set(username: string, { 77 | password = null, 78 | email = null, 79 | firstName = null, 80 | lastName = null, 81 | }: any): Promise { 82 | const dbUser = await this.find(username); 83 | let changed = false; 84 | 85 | if (!dbUser) { 86 | console.log(`User ${username} not found`); 87 | return; 88 | } 89 | 90 | if (password?.length) { 91 | console.log(`Updating password for ${username}`); 92 | dbUser.password = password; 93 | changed = true; 94 | } 95 | 96 | if (email?.length) { 97 | if (!email.includes('@')) { 98 | throw new ValidationError('Invalid email'); 99 | } 100 | 101 | dbUser.email = email; 102 | changed = true; 103 | } 104 | 105 | if (firstName?.length) { 106 | dbUser.firstName = firstName; 107 | changed = true; 108 | } 109 | 110 | if (lastName?.length) { 111 | dbUser.lastName = lastName; 112 | changed = true; 113 | } 114 | 115 | if (changed) { 116 | await dbUser.save(); 117 | } 118 | } 119 | 120 | public async create({ 121 | username, 122 | email, 123 | password, 124 | firstName = null, 125 | lastName = null, 126 | }: any): Promise { 127 | const dbUser = await $db.User().create({ 128 | username, 129 | email, 130 | password: User.hashPassword(password), 131 | firstName, 132 | lastName, 133 | }); 134 | 135 | return new User(dbUser.dataValues); 136 | } 137 | } 138 | 139 | export default Users; 140 | -------------------------------------------------------------------------------- /frontend/src/components/api/TokenForm.vue: -------------------------------------------------------------------------------- 1 |