Compare commits

..

45 commits

Author SHA1 Message Date
scott 0051d87d47 Merge pull request 'Feature: User auth' (#15) from scott/kalkutago:feature/user-auth into main
Reviewed-on: https://git.tams.tech/TWS/kalkutago/pulls/15
2023-08-27 12:00:56 +00:00
D. Scott Boggs 033f2a561b Nit 2023-08-26 14:51:14 -04:00
D. Scott Boggs 019ccda845 Rename variable 2023-08-26 14:50:05 -04:00
D. Scott Boggs bc6f06e210 DEFAULT_COST + 2 is 2 much 2023-08-26 11:21:36 -04:00
D. Scott Boggs 424bc15512 prod needs the cookie secret too, arguably more than the others! 2023-08-26 11:18:23 -04:00
D. Scott Boggs 89b0180989 Move API calls from state to Track methods 2023-08-26 11:05:15 -04:00
D. Scott Boggs cd16208dd7 Move dateQuery to util.ts 2023-08-26 11:01:14 -04:00
D. Scott Boggs dafdd491f9 Move logOut function into method on state 2023-08-26 07:03:16 -04:00
D. Scott Boggs 003383e455 Fixed logout flow 2023-08-26 06:54:42 -04:00
D. Scott Boggs 5aa16762f7 Quick tweak 2023-08-26 06:54:17 -04:00
D. Scott Boggs db3ef7640a Renamed a variable
sorry it was bothering me.
2023-08-26 06:54:06 -04:00
scott f32a188750 Merge pull request 'Frontend was merged prematurely, this fixes that' (#3) from frontend/feature/login-view into feature/user-auth
Reviewed-on: https://git.tams.tech/scott/kalkutago/pulls/3
2023-08-26 10:18:06 +00:00
D. Scott Boggs bfffacabf6 Add logout button 2023-08-26 06:17:42 -04:00
D. Scott Boggs 290218eefe Restore logged-in state on page reload 2023-08-26 06:17:42 -04:00
D. Scott Boggs ffc1c6806a Fix shell.nix 2023-08-26 06:17:42 -04:00
D. Scott Boggs 37426aaa52 WIP: Add busy indicators to login view 2023-08-26 06:17:42 -04:00
D. Scott Boggs db72a6df17 Add busy-indicator to login/signup buttons 2023-08-26 06:17:42 -04:00
scott 3dbe2d2327 Merge pull request 'Backend feature: user auth' (#1) from backend/feature/user-auth into feature/user-auth
Reviewed-on: https://git.tams.tech/scott/kalkutago/pulls/1
2023-08-26 10:15:14 +00:00
D. Scott Boggs f6fb736ff7 Add log-out route (necessary to delete secret cookie) 2023-08-26 06:09:04 -04:00
D. Scott Boggs a8e4e5145b add tests 2023-08-26 06:09:04 -04:00
D. Scott Boggs 1c400e7ffa Remove no-longer-existing Rust feature default_free_fn 2023-08-26 06:09:04 -04:00
D. Scott Boggs d7285a84bb Fix authenticated track insertion 2023-08-26 06:09:04 -04:00
D. Scott Boggs 205a3b165e Rename user -> users 2023-08-26 06:09:04 -04:00
D. Scott Boggs f44f15d2b6 tracks are now relative to an authenticated user 2023-08-26 06:09:04 -04:00
D. Scott Boggs 46a9374571 Add entity and relations for user-tracks relationship 2023-08-26 06:09:04 -04:00
D. Scott Boggs 05bda8deb0 Add users-to-tracks many-to-many association 2023-08-26 06:09:04 -04:00
D. Scott Boggs 2485740291 Add login+sign_up routes and auth guard 2023-08-26 06:09:04 -04:00
D. Scott Boggs 792779a36d Add support for rocket's "secret cookies" 2023-08-26 06:09:04 -04:00
D. Scott Boggs 0a197db93f Add user model 2023-08-26 06:09:04 -04:00
D. Scott Boggs a390f79a75 Add Login view 2023-06-27 05:59:46 -04:00
D. Scott Boggs e25301655b remove extra console.log call 2023-06-25 15:28:21 -04:00
scott 7f2e12e913 Merge pull request 'Feature: Add Track Form' (#7) from scott/kalkutago:feature/add-track-form into main
Reviewed-on: https://git.tams.tech/TWS/kalkutago/pulls/7
2023-06-25 19:25:58 +00:00
D. Scott Boggs eb14a39c33 finishing touches 2023-06-25 15:24:39 -04:00
D. Scott Boggs 6360f23209 Add event handlers for updates to tracks 2023-06-25 15:20:16 -04:00
D. Scott Boggs 678e95bba0 Add NewTrackView 2023-06-25 15:20:16 -04:00
D. Scott Boggs c7cfa86be1 Add vue router 2023-06-25 15:20:16 -04:00
D. Scott Boggs 55816a486a Move state initialization from App.vue to main.ts 2023-06-25 15:20:16 -04:00
scott 75d9bab34d Merge pull request 'Fix bug where clearing ticks on one day would clear all ticks for the track on all days' (#14) from scott/kalkutago:fix/tick-deletion into main
Reviewed-on: https://git.tams.tech/TWS/kalkutago/pulls/14
2023-06-25 19:19:21 +00:00
D. Scott Boggs bfe0537750 Fix bug where clearing ticks on one day would clear all ticks for the track on all days 2023-06-25 15:17:59 -04:00
scott 9b876511d9 Merge pull request 'Add updates for track changes' (#9) from scott/kalkutago:feature/add-track-events into main
Reviewed-on: https://git.tams.tech/TWS/kalkutago/pulls/9
2023-06-25 18:33:57 +00:00
D. Scott Boggs 7a1dee61c9 Add updates for track changes 2023-06-25 14:32:02 -04:00
D. Scott Boggs b81485a3d5 Cleanly disconnect from the update stream if possible 2023-06-25 11:33:18 -04:00
D. Scott Boggs 9e16306c61 Remove a bit of cruft 2023-06-25 11:33:18 -04:00
D. Scott Boggs 741429630b Build custom image for client dev server 2023-06-25 11:33:18 -04:00
D. Scott Boggs e84e9e6594 Add README 2023-06-25 11:30:45 -04:00
41 changed files with 1457 additions and 147 deletions

View file

@ -7,8 +7,10 @@ client/dist/index.html:
build-client: client/dist/index.html
start-server: build-client
-mkdir db.mount
docker compose up --build -d
clean:
docker compose down
rm -r server/public/ client/dist/
-rm -r server/public/ client/dist/

60
README.md Normal file
View file

@ -0,0 +1,60 @@
# Kalkutago
A multi-user Tickmate clone for the web
## Development
### Before you start
The backend uses a postgres server which is not tracked as a part of the
repository, and neither is the password for it. You'll need to generate a
password by doing something like
```console
openssl rand -base64 36 > server/postgres.pw
```
The development environment uses Traefik to reverse-proxy the API and dev
server. In production, the API will serve the client directly, but this system
allows you to run the dev server behind a reverse proxy so that changes are
rendered quickly without having to rebuild the container. Using the vite dev
server also gets us better debugging in the browser.
However, that means that Traefik needs a hostname to listen for. The way I've
done this is by adding the following entry to my `/etc/hosts` file:
```
127.0.0.1 kalkutago
```
### Server
The server is written in Rust, using Rocket and SeaOrm. It uses Rust nightly, so
you'll need to `rustup override set nightly`, though it gets built in docker so
you really only need this for IDE support.
### Client
The client is a Vue application. It has a central state in
[`state.ts`](client/src/state.ts). On application load, the state is initialized
by fetching the current tracks and ticks via the API, and then subscribes to
updates from the server by opening an EventSource at `/api/v1/updates`.
The basic flow is:
- a user interaction happens which should trigger a state change
- the action is dispatched to the server (e.g. by making a PATCH request to
`/api/v1/tracks/<track_id>/ticked`)
- the server makes the appropriate change to the database
- an event is dispatched to the event sources subscribed via `/api/v1/updates`
- The client receives the event, the reactive state is updated, and the UI
changes to match the expected state.
### Running for development
`docker-compose_dev.yml` is symlinked to `docker-compose.yml` for now, so once
the hostname is set up Just run `docker-compose up --build` and visit
http://kalkutago/ to open the app
### Populating the database
In order to see the UI components I add a track to the database like
```console
curl --json '{"name": "test", "description": "test track", "icon": "❓", "enabled": 1}' kalkutago/api/v1/tracks
```

View file

@ -1,7 +1,11 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import NavBar from './components/NavBar.vue'
</script>
<template>
<RouterView />
<div >
<NavBar />
<RouterView />
</div>
</template>

View file

@ -0,0 +1,37 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import { state } from '../state'
</script>
<template>
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<h1 class="title navbar-item">Kalkutago</h1>
</div>
<div class="navbar-menu"></div>
<div class="navbar-end">
<div class="navbar-item">
<RouterLink to="/" v-if="$route.path === '/new-track'">
<button class="button is-info">
Go Back
</button>
</RouterLink>
<RouterLink to="/new-track" v-else>
<button class="button is-primary">
Add Track
</button>
</RouterLink>
</div>
<div class="navbar-item">
<button class="button is-info" @click="state.logOut()">
Log Out
</button>
</div>
<div class="navbar-item">
<!-- spacer -->
&nbsp;
</div>
</div>
</nav>
</template>

View file

@ -2,7 +2,9 @@
<table class="table">
<thead>
<th>Date</th>
<th v-for="track in state.tracks" :key="track.id">{{ track.icon }}</th>
<th v-for="track in state.tracks" :key="track.id">
<TrackIcon :icon="track.icon" :id="track.id" />
</th>
</thead>
<tbody>
<tr v-for="date in dates" :key="date.valueOf()">
@ -18,6 +20,7 @@
<script setup lang="ts">
import TickComponent from "./TickComponent.vue";
import { state } from "../state";
import TrackIcon from "./TrackIcon.vue";
const today = new Date()
const ONE_DAY_MS = 86_400_000

View file

@ -15,8 +15,8 @@ const className = computed(() => isSet.value ? "button is-rounded is-info" : "bu
async function toggle() {
if (isSet.value) {
await state.taskMarkedIncomplete(props.track)
await props.track.markIncomplete(props.date)
} else
await state.taskCompleted(props.track, props.date)
await props.track.markComplete(props.date)
}
</script>

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import { Track } from '../track';
const props = defineProps<{icon: String, id: number|undefined}>()
const del = () => {
if(props.id)
if(confirm("are you sure you want to delete this track?"))
Track.deleteById(props.id)
}
</script>
<template>
<div @click=del>
{{props.icon}}
</div>
</template>

View file

@ -1,10 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'
import TableView from './views/TableView.vue'
import NewTrackView from './views/NewTrackView.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: TableView }
{ path: '/', component: TableView },
{ path: '/new-track', component: NewTrackView },
{ path: '/login', component: import('./views/Login.vue') }
// for other pages:
// {path: '/', component: import('./views/TableView.vue')}
]

View file

@ -1,6 +1,9 @@
import { reactive } from "vue"
import { Track } from "./track"
import { Tick } from './ticks'
import { error } from "./error"
import { getCookie } from "./util";
import router from './router'
enum State {
Unfetched,
@ -8,30 +11,44 @@ enum State {
Fetched,
}
export const state = reactive({
tracks: new Array<Track>,
state: State.Unfetched,
interface LoggedInUser {
name: string
}
class AppState {
tracks: Array<Track>
state: State
user?: LoggedInUser
source?: EventSource
constructor() {
this.tracks = new Array<Track>
this.state = State.Unfetched
const name = getCookie("name")
if (name) this.user = { name }
}
streamUpdatesFromServer() {
const source = new EventSource("/api/v1/updates")
source.addEventListener("open", () => console.debug("opened event source"))
source.addEventListener('message', event => console.log(event))
source.addEventListener('TickAdded', event => {
console.log(event)
const tick: Tick = JSON.parse(event.data)
const tick: Tick = Tick.fromJSON(JSON.parse(event.data))
const tracks = this.tracks.map(track => {
if (track.id === tick.track_id) {
const ticks = track.ticks ?? []
ticks.push(tick)
track.ticks = ticks
}
return track
})
this.tracks = tracks
})
source.addEventListener('TrackAdded', ({ data }) => {
const track: Track = Track.fromJSON(JSON.parse(data))
this.tracks = [track, ...this.tracks]
})
source.addEventListener('TickDropped', event => {
console.log(event)
const tick: Tick = JSON.parse(event.data)
const tick: Tick = Tick.fromJSON(JSON.parse(event.data))
const tracks = this.tracks.map(track => {
if (track.id === tick.track_id) {
track.ticks = track.ticks?.filter($tick => $tick.id !== tick.id)
@ -40,6 +57,14 @@ export const state = reactive({
})
this.tracks = tracks
})
source.addEventListener('TrackDropped', ({ data }) => {
const track: Track = Track.fromJSON(JSON.parse(data))
this.tracks = this.tracks.filter($track => $track.id !== track.id)
})
source.addEventListener('TrackChanged', ({ data }) => {
const track: Track = Track.fromJSON(JSON.parse(data))
this.tracks = this.tracks.map($track => $track.id === track.id ? track : $track)
})
source.addEventListener('Lagged', event => {
console.log(event)
// Refresh the page, refetching the list of tracks and ticks
@ -50,34 +75,30 @@ export const state = reactive({
window.location = window.location
})
window.addEventListener('beforeunload', () => source.close())
},
this.source = source
}
async repopulate() {
if (!this.user) {
this.tracks = []
return
}
this.state = State.Fetching
this.tracks = await Track.fetchAll()
},
this.source?.close()
this.streamUpdatesFromServer()
this.state = State.Fetched
}
async populate() {
if (this.state != State.Unfetched) return
await this.repopulate()
this.streamUpdatesFromServer()
this.state = State.Fetched
},
async taskCompleted(track: Track, date: Date): Promise<Tick> {
let query = new URLSearchParams()
query.append("year", date.getUTCFullYear().toString())
query.append("month", (date.getUTCMonth() + 1).toString())
// good thing I still had this ^^^^^^^^^^^^^^ in mind when I wrote this 😬
query.append("day", date.getUTCDate().toString())
const response: Response = await fetch(`/api/v1/tracks/${track.id}/ticked?${query.toString()}`, { method: "PATCH" })
const body = await response.text()
if (!response.ok) {
error(body)
throw new Error(`error setting tick for track ${track.id} ("${track.name}"): ${response.status} ${response.statusText}`)
}
return JSON.parse(body)
},
async taskMarkedIncomplete(track: Track) {
const { ok, status, statusText } = await fetch(`/api/v1/tracks/${track.id}/all-ticks`, { method: 'DELETE' })
if (!ok)
error(`error deleting ticks for ${track.id}: ${statusText} (${status})`)
}
})
async logOut() {
const result = await fetch('/api/v1/auth', {method: 'DELETE'})
if(!result.ok) return error('failed to log out')
this.user = undefined
router.push('/login')
}
}
export const state = reactive(new AppState)

View file

@ -1,4 +1,4 @@
interface ITick {
export interface ITick {
id: number
track_id?: number
year?: number
@ -10,7 +10,7 @@ interface ITick {
has_time_info?: number
}
class Tick implements ITick {
export class Tick implements ITick {
id: number
track_id?: number
year?: number

View file

@ -1,7 +1,9 @@
import { error } from "./error"
import { Tick, ITick } from './ticks'
import { dateQuery } from "./util"
export interface ITrack {
id: number
id?: number
name: String
description: String
icon: String
@ -13,7 +15,7 @@ export interface ITrack {
}
export class Track implements ITrack {
id: number
id?: number
name: String
description: String
icon: String
@ -24,7 +26,7 @@ export class Track implements ITrack {
ticks?: Array<Tick>
constructor(
id: number,
id: number | undefined,
name: String,
description: String,
icon: String,
@ -47,6 +49,34 @@ export class Track implements ITrack {
this.fetchTicks = this.fetchTicks.bind(this)
}
/**
* Add this track to the database. A `TrackAdded` event should have been
* received from the server on the event stream by the time this returns.
*
* @returns whether or not the query succeeded
*/
async create(): Promise<boolean> {
// note that this.id is expected to be `undefined` here.
const response = await fetch('/api/v1/tracks', {
method: "POST",
body: JSON.stringify(this),
headers: { "Content-Type": "application/json" }
})
if (!response.ok)
error(`error submitting track ${this.name}: ${response.statusText} (${response.status})`)
return response.ok
}
async delete() {
const id = this.id
if (id) await Track.deleteById(id)
}
static async deleteById(id: number) {
const response = await fetch(`/api/v1/tracks/${id}`, { method: "DELETE" })
if (!response.ok) error(`error deleting track with ID ${id}: ${response.statusText} (${response.status})`)
}
static fromJSON(track: ITrack): Track {
return new Track(track.id, track.name, track.description, track.icon, track.enabled, track.multiple_entries_per_day, track.color, track.order)
}
@ -97,4 +127,36 @@ export class Track implements ITrack {
}
return []
}
}
/**
* Mark this track as being completed on the given date. A `TickAdded` event
* should have been received from the server on the event stream by the time
* this returns.
*
* @param date the date the task was completed
* @returns the decoded server API response
*/
async markComplete(date: Date) {
const query = dateQuery(date)
const response: Response = await fetch(`/api/v1/tracks/${this.id}/ticked?${query.toString()}`, { method: "PATCH" })
const body = await response.text()
if (!response.ok) {
error(body)
throw new Error(`error setting tick for track ${this.id} ("${this.name}"): ${response.status} ${response.statusText}`)
}
return JSON.parse(body)
}
/**
* Mark this track as being incomplete on the given date. A `TickAdded` event
* should have been received from the server on the event stream by the time
* this returns.
*
* @param date the date the task was completed
* @returns the decoded server API response
*/
async markIncomplete(date: Date) {
const query = dateQuery(date)
const { ok, status, statusText } = await fetch(`/api/v1/tracks/${this.id}/all-ticks?${query.toString()}`, { method: 'DELETE' })
if (!ok)
error(`error deleting ticks for ${this.id}: ${statusText} (${status})`)
}
}

17
client/src/util.ts Normal file
View file

@ -0,0 +1,17 @@
export function getCookie(key: string): string | null {
const start = document.cookie.indexOf(key + '=')
if(start === -1) return null
let end: number | undefined = document.cookie.indexOf(';', start)
if(end === -1)
end = undefined
return document.cookie.substring(start + key.length + 1, end)
}
export function dateQuery(date: Date): URLSearchParams {
let query = new URLSearchParams()
query.set("year", date.getUTCFullYear().toString())
query.set("month", (date.getUTCMonth() + 1).toString())
// good thing I still had this ^^^^^^^^^^^^^^ in mind when I wrote this 😬
query.set("day", date.getUTCDate().toString())
return query
}

View file

@ -0,0 +1,84 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { state } from '../state';
import router from '../router'
const $name = ref("")
const $password = ref("")
const signUpWait = ref(false)
const loginWait = ref(false)
const signUpClass = computed(() => `submit button is-success ${signUpWait.value ? 'is-loading' : ''}`)
const loginClass = computed(() => `submit button is-info ${loginWait.value ? 'is-loading' : ''}`)
async function signUp() {
const name = $name.value, password = $password.value
signUpWait.value = true
const result = await fetch("/api/v1/auth", {
method: 'POST',
body: JSON.stringify({ name, password }),
headers: {'Content-Type': 'application/json'}
})
if (result.ok) {
state.user = { name }
await state.repopulate()
router.push("/")
}
}
async function login() {
const name = $name.value, password = $password.value
loginWait.value = true
const result = await fetch("/api/v1/auth", {
method: 'PUT',
body: JSON.stringify({ name, password }),
headers: {'Content-Type': 'application/json'}
})
if (result.ok) {
state.user = { name }
await state.repopulate()
router.push("/")
}
}
if(state.user?.name) router.push("/")
</script>
<template>
<div class="modal is-active">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<div class="modal-card-title">Log in or Sign up</div>
</header>
<section class="modal-card-body">
<div class="field">
<label for="username" class=label>Name</label>
<div class="control">
<input type="text" name="username" class="input" v-model="$name" />
</div>
</div>
<div class="field">
<label for="password" class="label">Password</label>
<div class="control">
<input type="password" name="password" class="input" v-model="$password" />
</div>
</div>
</section>
<footer class="modal-card-foot">
<button :class="loginClass" @click="login">Log in</button>
<button :class="signUpClass" @click="signUp">Sign Up</button>
</footer>
</div>
</div>
</template>
<style scoped>
.modal-card-foot {
flex-direction: row-reverse;
}
.button.submit {
margin-left: 10px;
}
</style>

View file

@ -0,0 +1,90 @@
<script setup lang="ts">
import { RouterLink, useRouter } from 'vue-router';
import { Track } from '../track';
import { computed, ref } from 'vue';
const props = defineProps<{ initialState?: Track }>()
const router = useRouter()
const name = ref(props.initialState?.name ?? "")
const description = ref(props.initialState?.description?.toString() ?? "")
const icon = ref(props.initialState?.icon ?? "")
const enabled = ref(props.initialState?.enabled ?? true)
const multipleEntriesPerDay = ref(props.initialState?.multiple_entries_per_day ?? false)
const color = ref(props.initialState?.color ?? undefined)
const order = ref<any>(props.initialState?.order ?? undefined)
const submittingNow = ref(false)
const submitButtonClass = computed(() => 'button is-primary' + (submittingNow.value ? ' is-loading' : ''))
const submit = async () => {
submittingNow.value = true
// if you make a change to order then erase the value in the box it's ""
if (order.value === "") order.value = undefined
if (order.value instanceof String || typeof order.value === 'string') order.value = Number(order.value)
const track = new Track(undefined, name.value, description.value,
icon.value, Number(enabled.value), Number(multipleEntriesPerDay.value),
color.value, order.value)
if (await track.create())
router.push('/')
}
</script>
<template>
<section class="section">
<div class="field">
<label for="name" class="label">Name</label>
<div class="control">
<input type="text" name="name" class="input" v-model="name" />
</div>
</div>
<div class="field">
<label for="description" class="label">Description</label>
<div class="control">
<textarea name="description" cols="30" rows="5" v-model="description"></textarea>
</div>
</div>
<div class="field">
<label for="icon" class="label">Icon</label>
<div class="control">
<input type="text" name="icon" class="input" v-model="icon" />
</div>
</div>
<div class="field is-grouped">
<div class="control">
<label for="enabled" class="label">
<input type="checkbox" name="enabled" class="checkbox" v-model="enabled" />
Enabled?
</label>
</div>
<div class="control">
<label for="multiple-entries" class="label">
<input type="checkbox" name="multiple-entries" class="checkbox" v-model="multipleEntriesPerDay" />
Multiple Entries per Day?
</label>
</div>
</div>
<div class="field">
<div class="control">
TODO color choice
</div>
</div>
<div class="field">
<div class="control">
<label for="order" class="label">
<input type="number" name="order" class="input" v-model="order" />
Order
</label>
</div>
</div>
<div class="buttons">
<RouterLink to="/">
<button class="button is-danger">
Cancel
</button>
</RouterLink>
<button :class="submitButtonClass" @click="submit">
Save
</button>
</div>
</section>
</template>

View file

@ -1,5 +1,10 @@
<script setup lang="ts">
import Table from "../components/Table.vue";
import { state } from '../state.ts'
import router from '../router.ts'
if(!state.user) router.push('/login')
</script>
<template>

View file

@ -14,7 +14,7 @@ services:
POSTGRES_USER: kalkutago
POSTGRES_DB: kalkutago
POSTGRES_HOST: database
secrets: [ postgres-password ]
secrets: [ postgres-password, cookie-secret ]
depends_on: [ database ]
expose: [ 8000 ]
# ports:
@ -25,6 +25,7 @@ services:
labels:
traefik.enable: true
traefik.http.routers.kalkutago_server.rule: 'Host(`kalkutago`) && PathPrefix(`/api`)'
database:
image: postgres
environment:
@ -65,6 +66,8 @@ services:
secrets:
postgres-password:
file: ./server/postgres.pw
cookie-secret:
file: ./server/cookie-secret.pw
networks:
internal:

View file

@ -14,7 +14,7 @@ services:
POSTGRES_USER: kalkutago
POSTGRES_DB: kalkutago
POSTGRES_HOST: database
secrets: [ postgres-password ]
secrets: [ postgres-password, cookie-secret ]
depends_on: [ database ]
volumes:
- ./client/dist:/src/public:ro
@ -32,6 +32,8 @@ services:
secrets:
postgres-password:
file: ./server/postgres.pw
cookie-secret:
file: ./server/cookie-secret.pw
networks:
internal:

45
docker-compose_test.yml Normal file
View file

@ -0,0 +1,45 @@
version: "3.5"
services:
server:
build:
context: ./server
dockerfile: Dockerfile.test
networks:
- web
- internal
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password
POSTGRES_USER: kalkutago
POSTGRES_DB: kalkutago_TEST
POSTGRES_HOST: database
secrets: [ postgres-password, cookie-secret ]
depends_on: [ database ]
expose: [ 8000 ]
volumes:
- ./client/dist:/src/public:ro
labels:
traefik.enable: false
database:
image: postgres
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password
POSTGRES_USER: kalkutago
POSTGRES_DB: kalkutago_TEST
secrets: [ postgres-password ]
networks: [ internal ]
labels:
traefik.enable: false
secrets:
postgres-password:
file: ${PG_PW_FILE}
cookie-secret:
file: ${COOKIE_SECRET_FILE}
networks:
internal:
internal: true
web:
external: true

164
server/Cargo.lock generated
View file

@ -8,6 +8,41 @@ version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "433cfd6710c9986c576a25ca913c39d66a6474107b406f34f91d4a8923395241"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "209b47e8954a928e1d72e86eca7000ebb6655fe1436d33eefc2201cad027e237"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "ahash"
version = "0.7.6"
@ -142,6 +177,19 @@ version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d"
[[package]]
name = "bcrypt"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df288bec72232f78c1ec5fe4e8f1d108aa0265476e93097593c803c8c02062a"
dependencies = [
"base64 0.21.2",
"blowfish",
"getrandom",
"subtle",
"zeroize",
]
[[package]]
name = "bigdecimal"
version = "0.3.1"
@ -192,6 +240,16 @@ dependencies = [
"generic-array",
]
[[package]]
name = "blowfish"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
dependencies = [
"byteorder",
"cipher",
]
[[package]]
name = "borsh"
version = "0.10.3"
@ -305,6 +363,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clap"
version = "3.2.25"
@ -347,7 +415,13 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24"
dependencies = [
"aes-gcm",
"base64 0.21.2",
"hkdf",
"percent-encoding",
"rand",
"sha2",
"subtle",
"time 0.3.22",
"version_check",
]
@ -393,9 +467,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"rand_core",
"typenum",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "darling"
version = "0.14.4"
@ -462,6 +546,17 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "derive_deref"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcdbcee2d9941369faba772587a565f4f534e42cb8d17e5295871de730163b2b"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "devise"
version = "0.4.1"
@ -756,6 +851,16 @@ dependencies = [
"wasi 0.11.0+wasi-snapshot-preview1",
]
[[package]]
name = "ghash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "glob"
version = "0.3.1"
@ -989,6 +1094,15 @@ version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
[[package]]
name = "inout"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
dependencies = [
"generic-array",
]
[[package]]
name = "instant"
version = "0.1.12"
@ -1049,8 +1163,10 @@ dependencies = [
name = "kalkutago-server"
version = "0.1.0"
dependencies = [
"bcrypt",
"chrono",
"derive_builder",
"derive_deref",
"either",
"femme",
"log",
@ -1061,6 +1177,7 @@ dependencies = [
"serde_json",
"thiserror",
"tokio",
"tokio-test",
]
[[package]]
@ -1249,6 +1366,12 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
[[package]]
name = "opaque-debug"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "os_str_bytes"
version = "6.5.1"
@ -1379,6 +1502,18 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "polyval"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef234e08c11dfcb2e56f79fd70f6f2eb7f025c0ce2333e82f4f0518ecad30c6"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "ppv-lite86"
version = "0.2.17"
@ -2503,6 +2638,19 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-test"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53474327ae5e166530d17f2d956afcb4f8a004de581b3cae10f12006bc8163e3"
dependencies = [
"async-stream",
"bytes",
"futures-core",
"tokio",
"tokio-stream",
]
[[package]]
name = "tokio-util"
version = "0.7.8"
@ -2699,6 +2847,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.7.1"
@ -3010,3 +3168,9 @@ name = "yansi"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec"
[[package]]
name = "zeroize"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9"

View file

@ -13,12 +13,15 @@ path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
bcrypt = "0.14.0"
chrono = "0.4.26"
derive_deref = "1.1.1"
femme = "2.2.1"
log = { version = "0.4.19", features = ["kv_unstable", "kv_unstable_serde"] }
sea-orm-migration = "0.11.3"
serde_json = "1.0.96"
thiserror = "1.0.40"
tokio-test = "0.4.2"
[dependencies.derive_builder]
version = "0.12.0"
@ -40,7 +43,7 @@ features = [
[dependencies.rocket]
git = "https://github.com/SergioBenitez/Rocket"
rev = "v0.5.0-rc.3"
features = ["json"]
features = ["json", "secrets"]
[dependencies.serde]
version = "1.0.163"

116
server/src/api/auth.rs Normal file
View file

@ -0,0 +1,116 @@
use derive_deref::Deref;
use either::Either::{self, Right};
use log::{as_debug, as_serde, debug};
use rocket::{
http::{Cookie, CookieJar, Status},
outcome::IntoOutcome,
request::{self, FromRequest},
serde::json::Json,
Request, State,
};
use sea_orm::{prelude::*, DatabaseConnection};
use serde::{Deserialize, Serialize};
use crate::{
api::error::ApiResult,
entities::{prelude::*, *},
error::Error,
};
use super::ErrorResponder;
#[derive(Clone, Deserialize, Serialize)]
pub struct LoginData {
pub name: String,
pub password: String,
}
#[put("/", data = "<user_data>", format = "application/json")]
pub(super) async fn login(
db: &State<DatabaseConnection>,
user_data: Json<LoginData>,
cookies: &CookieJar<'_>,
) -> Result<Status, Either<Status, ErrorResponder>> {
let user = Users::find()
.filter(users::Column::Name.eq(&user_data.name))
.one(db as &DatabaseConnection)
.await
.map_err(|err| Right(Error::from(err).into()))?;
let Some(user) = user else {
info!(name = user_data.name; "no user found with the given name");
return Ok(Status::Unauthorized);
};
let user = user.check_password(&user_data.password)?;
cookies.add_private(Cookie::new(
"user",
serde_json::to_string(&user).map_err(|err| Right(Error::from(err).into()))?,
));
cookies.add(Cookie::new("name", user.name));
Ok(Status::Ok)
}
#[post("/", data = "<user_data>", format = "application/json")]
pub(super) async fn sign_up(
db: &State<DatabaseConnection>,
user_data: Json<LoginData>,
cookies: &CookieJar<'_>,
) -> ApiResult<()> {
let user_data = users::ActiveModel::new(&user_data.name, &user_data.password)?
.insert(db as &DatabaseConnection)
.await
.map_err(Error::from)?;
debug!(user = as_serde!(user_data); "user added");
cookies.add_private(Cookie::new(
"user",
serde_json::to_string(&user_data).map_err(Error::from)?,
));
cookies.add(Cookie::new("name", user_data.name));
Ok(())
}
#[delete("/")]
pub(super) async fn sign_out(cookies: &CookieJar<'_>) {
cookies.remove_private(Cookie::named("user"));
cookies.remove(Cookie::named("name"));
}
/// Authentication guard
#[derive(Deref)]
pub(super) struct Auth(users::Model);
#[derive(Deserialize)]
struct AuthData {
id: i32,
name: String,
password_hash: String,
}
impl From<AuthData> for Auth {
fn from(value: AuthData) -> Self {
Auth(users::Model {
id: value.id,
name: value.name,
password_hash: value.password_hash,
})
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Auth {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let unauthorized = (Status::Unauthorized, ());
let Some(user) = request.cookies().get_private("user") else {
return request::Outcome::Failure(unauthorized);
};
let user = user.value();
debug!(user = user; "user retreived from private cookie");
let result = serde_json::from_str(user)
.ok()
.map(|model: AuthData| model.into())
.into_outcome(unauthorized);
debug!(result = as_debug!(result); "auth FromRequest return value");
result
}
}

View file

@ -2,11 +2,11 @@ use crate::error::Error;
#[derive(Responder)]
#[response(status = 500, content_type = "json")]
pub(crate) struct ErrorResponder {
pub struct ErrorResponder {
message: String,
}
pub(crate) type ApiResult<T> = Result<T, ErrorResponder>;
pub type ApiResult<T> = Result<T, ErrorResponder>;
// The following impl's are for easy conversion of error types.

View file

@ -8,10 +8,7 @@ use crate::error::Error;
use super::error::ApiResult;
#[post("/dump", data = "<sql_dump>")]
pub(crate) async fn sql_dump(
db: &State<DatabaseConnection>,
sql_dump: &str,
) -> ApiResult<Status> {
pub(crate) async fn sql_dump(db: &State<DatabaseConnection>, sql_dump: &str) -> ApiResult<Status> {
for line in sql_dump.lines() {
let line = line.to_ascii_lowercase();
if line.starts_with("insert into")
@ -25,11 +22,8 @@ pub(crate) async fn sql_dump(
Ok(Status::Ok)
}
#[post("/", data="<sqlite_db>")]
pub(crate) async fn db_file(
db: &State<DatabaseConnection>,
sqlite_db: &[u8],
) -> ApiResult<Status> {
#[post("/", data = "<sqlite_db>")]
pub(crate) async fn db_file(db: &State<DatabaseConnection>, sqlite_db: &[u8]) -> ApiResult<Status> {
use std::{
io::Write,
process::{Command, Stdio},
@ -51,8 +45,6 @@ pub(crate) async fn db_file(
if result.status.success() {
sql_dump(db, &String::from_utf8(result.stdout).map_err(Error::from)?).await
} else {
Err(Error::SqliteCommandError(String::from_utf8_lossy(
&result.stderr,
).to_string()).into())
Err(Error::SqliteCommandError(String::from_utf8_lossy(&result.stderr).to_string()).into())
}
}

View file

@ -1,3 +1,4 @@
mod auth;
mod error;
mod groups;
#[cfg(feature = "unsafe_import")]
@ -7,12 +8,14 @@ mod tracks;
pub(crate) mod update;
use std::{
default::default,
default::Default,
env, fs,
net::{IpAddr, Ipv4Addr},
};
use crate::error::Error;
use rocket::{
config::SecretKey,
fs::{FileServer, NamedFile},
response::stream::EventStream,
routes, Build, Config, Rocket, State,
@ -25,6 +28,8 @@ use tokio::sync::broadcast::{self, error::RecvError, Sender};
use self::{error::ApiResult, update::Update};
use log::{as_debug, as_serde, debug, trace};
pub use auth::LoginData;
#[get("/status")]
fn status() -> &'static str {
"Ok"
@ -61,7 +66,17 @@ async fn spa_index_redirect() -> ApiResult<NamedFile> {
.map_err(Error::from)?)
}
pub(crate) fn start_server(db: DatabaseConnection) -> Rocket<Build> {
fn get_secret() -> [u8; 32] {
let path =
env::var("COOKIE_SECRET_FILE").unwrap_or_else(|_| "/run/secrets/cookie-secret".into());
let file_contents =
fs::read(&path).unwrap_or_else(|err| panic!("failed to read from {path:?}: {err:?}"));
let mut data = [0u8; 32];
data.copy_from_slice(&file_contents);
data
}
pub fn start_server(db: DatabaseConnection) -> Rocket<Build> {
use groups::*;
use ticks::*;
use tracks::*;
@ -69,7 +84,8 @@ pub(crate) fn start_server(db: DatabaseConnection) -> Rocket<Build> {
let it = rocket::build()
.configure(Config {
address: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
..default()
secret_key: SecretKey::derive_from(&get_secret()),
..Config::default()
})
.register("/", catchers![spa_index_redirect])
.manage(db)
@ -87,6 +103,7 @@ pub(crate) fn start_server(db: DatabaseConnection) -> Rocket<Build> {
ticked,
ticked_on_date,
clear_all_ticks,
clear_all_ticks_on_day,
],
)
.mount(
@ -97,6 +114,7 @@ pub(crate) fn start_server(db: DatabaseConnection) -> Rocket<Build> {
"/api/v1/groups",
routes![all_groups, group, insert_group, update_group, delete_group],
)
.mount("/api/v1/auth", routes![auth::login, auth::sign_up, auth::sign_out])
.mount("/", FileServer::from("/src/public"));
#[cfg(feature = "unsafe_import")]

View file

@ -1,52 +1,71 @@
use crate::api::auth::Auth;
use crate::api::{self, error::ApiResult};
use crate::entities::{prelude::*, *};
use crate::error::Error;
use either::Either::{self, Left, Right};
use log::as_debug;
use log::{as_serde, debug, warn};
use rocket::http::Status;
use rocket::{serde::json::Json, State};
use sea_orm::{prelude::*, DatabaseConnection};
use std::default::default;
use sea_orm::{prelude::*, DatabaseConnection, IntoActiveModel, Statement};
use tokio::sync::broadcast::Sender;
use super::update::Update;
use super::ErrorResponder;
#[get("/")]
pub(super) async fn all_tracks(
db: &State<DatabaseConnection>,
authorized_user: Auth,
) -> ApiResult<Json<Vec<tracks::Model>>> {
let db = db as &DatabaseConnection;
let tracks = Tracks::find().all(db).await.unwrap();
let tracks = authorized_user
.find_related(Tracks)
.all(db)
.await
.map_err(Error::from)?;
Ok(Json(tracks))
}
async fn get_track_check_user(
db: &DatabaseConnection,
track_id: i32,
user: &users::Model,
) -> Result<Json<tracks::Model>, Either<Status, api::ErrorResponder>> {
if let Some(Some(track)) = user
.find_related(Tracks)
.filter(tracks::Column::Id.eq(track_id))
.one(db)
.await
.transpose()
.map(|it| it.ok())
{
Ok(Json(track))
} else {
Err(Left(Status::NotFound))
}
}
#[get("/<id>")]
pub(super) async fn track(
db: &State<DatabaseConnection>,
id: i32,
auth: Auth,
) -> Result<Json<tracks::Model>, Either<Status, api::ErrorResponder>> {
let db = db as &DatabaseConnection;
match Tracks::find_by_id(id).one(db).await {
Ok(Some(track)) => Ok(Json(track)),
Ok(None) => Err(Left(Status::NotFound)),
Err(err) => Err(Right(Error::from(err).into())),
}
get_track_check_user(db, id, &auth).await
}
#[get("/<id>/ticks")]
pub(super) async fn ticks_for_track(
db: &State<DatabaseConnection>,
id: i32,
auth: Auth,
) -> Result<Json<Vec<ticks::Model>>, Either<Status, api::ErrorResponder>> {
let db = db as &DatabaseConnection;
match Tracks::find_by_id(id).one(db).await {
Ok(Some(track)) => {
let result = track.find_related(Ticks).all(db).await;
match result {
Ok(ticks) => Ok(Json(ticks)),
Err(err) => Err(Right(Error::from(err).into())),
}
}
Ok(None) => Err(Left(Status::NotFound)),
let track = get_track_check_user(db, id, &auth).await?;
let result = track.find_related(Ticks).all(db).await;
match result {
Ok(ticks) => Ok(Json(ticks)),
Err(err) => Err(Right(Error::from(err).into())),
}
}
@ -54,37 +73,128 @@ pub(super) async fn ticks_for_track(
#[post("/", format = "application/json", data = "<track>")]
pub(super) async fn insert_track(
db: &State<DatabaseConnection>,
tx: &State<Sender<Update>>,
track: Json<serde_json::Value>,
) -> ApiResult<Json<tracks::Model>> {
let track = track.0;
let db = db as &DatabaseConnection;
let mut model: tracks::ActiveModel = default();
model.set_from_json(track).map_err(Error::from)?;
Ok(Json(model.insert(db).await.map_err(Error::from)?))
auth: Auth,
) -> Result<Json<tracks::Model>, Either<Status, ErrorResponder>> {
debug!(
user=as_serde!(*auth),
track=as_serde!(track.0);
"authenticated user making track insertion request"
);
fn bad() -> Either<Status, ErrorResponder> {
Left(Status::BadRequest)
}
fn bad_value_for(key: &'static str) -> impl Fn() -> Either<Status, ErrorResponder> {
move || {
warn!(key = key; "bad value");
bad()
}
}
let track = track.0.as_object().ok_or_else(|| {
warn!("received value was not an object");
bad()
})?;
let Some(track_id) = db
.query_one(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
r#"with track_insertion as (
insert into tracks (name, description, icon, enabled,
multiple_entries_per_day, color, "order"
) values (
$2, $3, $4, $5, $6, $7, $8
) returning id
)
insert into user_tracks (
user_id, track_id
) select $1, ti.id
from track_insertion ti
join track_insertion using (id)
returning id;"#,
[
auth.id.into(),
track
.get("name")
.ok_or_else(bad_value_for("name"))?
.as_str()
.ok_or_else(bad_value_for("name"))?
.into(),
track
.get("description")
.ok_or_else(bad_value_for("description"))?
.as_str()
.ok_or_else(bad_value_for("description"))?
.into(),
track
.get("icon")
.ok_or_else(bad_value_for("icon"))?
.as_str()
.ok_or_else(bad_value_for("icon"))?
.into(),
track.get("enabled").and_then(|it| it.as_i64()).into(),
track
.get("multiple_entries_per_day")
.and_then(|it| it.as_i64())
.into(),
track.get("color").and_then(|it| it.as_i64()).into(),
track.get("order").and_then(|it| it.as_i64()).into(),
],
))
.await
.map_err(|err| Right(Error::from(err).into()))?
else {
return Err(Right("no value returned from track insertion query".into()));
};
trace!("query completed");
let track_id = track_id
.try_get_by_index(0)
.map_err(|err| Right(Error::from(err).into()))?;
trace!(track_id = track_id; "freshly inserted track ID");
let track = auth.authorized_track(track_id, db).await.ok_or_else(|| {
Right(format!("failed to fetch freshly inserted track with id {track_id}").into())
})?;
if let Err(err) = tx.send(Update::track_added(track.clone())) {
warn!(err = as_debug!(err); "error sending updates to subscribed channels");
}
Ok(Json(track))
}
#[put("/", format = "application/json", data = "<track>")]
pub(super) async fn update_track(
db: &State<DatabaseConnection>,
track: Json<serde_json::Value>,
) -> ApiResult<Json<tracks::Model>> {
tx: &State<Sender<Update>>,
track: Json<tracks::Model>,
authorized_user: Auth,
) -> Result<Json<tracks::Model>, Either<Status, api::ErrorResponder>> {
let db = db as &DatabaseConnection;
Ok(Json(
tracks::ActiveModel::from_json(track.0)
.map_err(Error::from)?
.update(db)
.await
.map_err(Error::from)?,
))
let track = track.0;
if !authorized_user.is_authorized_for(track.id, db).await {
return Err(Left(Status::Forbidden));
}
let track = track
.into_active_model()
.update(db)
.await
.map_err(|err| Right(Error::from(err).into()))?;
if let Err(err) = tx.send(Update::track_changed(track.clone())) {
warn!(err = as_debug!(err); "error sending updates to subscribed channels");
}
Ok(Json(track))
}
#[delete("/<id>")]
pub(super) async fn delete_track(db: &State<DatabaseConnection>, id: i32) -> ApiResult<Status> {
pub(super) async fn delete_track(
db: &State<DatabaseConnection>,
tx: &State<Sender<Update>>,
id: i32,
authorized_user: Auth,
) -> ApiResult<Status> {
let db = db as &DatabaseConnection;
Tracks::delete_by_id(id)
.exec(db)
.await
.map_err(Error::from)?;
let Some(track) = authorized_user.authorized_track(id, db).await else {
return Ok(Status::NotFound);
};
track.clone().delete(db).await.map_err(Error::from)?;
tx.send(Update::track_removed(track)).map_err(Error::from)?;
Ok(Status::Ok)
}
@ -93,15 +203,20 @@ pub(super) async fn ticked(
db: &State<DatabaseConnection>,
tx: &State<Sender<Update>>,
id: i32,
) -> ApiResult<Json<ticks::Model>> {
authorized_user: Auth,
) -> Result<Json<ticks::Model>, Either<Status, api::ErrorResponder>> {
if !authorized_user.is_authorized_for(id, db).await {
return Err(Left(Status::Forbidden));
}
let tick = ticks::ActiveModel::now(id);
let tick = tick
.insert(db as &DatabaseConnection)
.await
.map_err(Error::from)?
.to_owned();
tx.send(Update::tick_added(tick.clone()))
.map_err(Error::from)?;
.map_err(|err| Right(Error::from(err).into()))?;
if let Err(err) = tx.send(Update::tick_added(tick.clone())) {
warn!(err = as_debug!(err); "error sending updates to subscribed channels");
}
Ok(Json(tick))
}
@ -113,7 +228,12 @@ pub(super) async fn ticked_on_date(
year: i32,
month: u32,
day: u32,
authorized_user: Auth,
) -> ApiResult<Either<Json<ticks::Model>, Status>> {
if !authorized_user.is_authorized_for(id, db).await {
return Ok(Right(Status::Forbidden));
}
let Some(date) = Date::from_ymd_opt(year, month, day) else {
return Ok(Right(Status::BadRequest));
};
@ -121,10 +241,10 @@ pub(super) async fn ticked_on_date(
let tick = tick
.insert(db as &DatabaseConnection)
.await
.map_err(Error::from)?
.to_owned();
tx.send(Update::tick_added(tick.clone()))
.map_err(Error::from)?;
if let Err(err) = tx.send(Update::tick_added(tick.clone())) {
warn!(err = as_debug!(err); "error sending updates to subscribed channels");
}
Ok(Left(Json(tick)))
}
@ -133,10 +253,14 @@ pub(super) async fn clear_all_ticks(
db: &State<DatabaseConnection>,
tx: &State<Sender<Update>>,
id: i32,
authorized_user: Auth,
) -> ApiResult<Either<Status, Json<Vec<ticks::Model>>>> {
let db = db as &DatabaseConnection;
let Some(track) = Tracks::find_by_id(id).one(db).await.map_err(Error::from)? else {
info!(track_id = id; "couldn't drop all ticks for track; track not found");
let Some(track) = authorized_user.authorized_track(id, db).await else {
info!(
track_id = id, user_id = authorized_user.id;
"couldn't drop all ticks for track; track not found or user not authorized"
);
return Ok(Left(Status::NotFound));
};
let ticks = track
@ -146,7 +270,40 @@ pub(super) async fn clear_all_ticks(
.map_err(Error::from)?;
for tick in ticks.clone() {
tick.clone().delete(db).await.map_err(Error::from)?;
Update::tick_cancelled(tick).send(&tx)?;
if let Err(err) = Update::tick_cancelled(tick).send(tx) {
warn!(err = as_debug!(err); "error sending updates to subscribed channels");
}
}
Ok(Right(Json(ticks)))
}
#[delete("/<id>/all-ticks?<year>&<month>&<day>")]
pub(super) async fn clear_all_ticks_on_day(
db: &State<DatabaseConnection>,
tx: &State<Sender<Update>>,
id: i32,
year: i32,
month: u32,
day: u32,
authorized_user: Auth,
) -> ApiResult<Either<Status, Json<Vec<ticks::Model>>>> {
let db = db as &DatabaseConnection;
if !authorized_user.is_authorized_for(id, db).await {
return Ok(Left(Status::Forbidden));
}
let ticks = Ticks::find()
.filter(ticks::Column::TrackId.eq(id))
.filter(ticks::Column::Year.eq(year))
.filter(ticks::Column::Month.eq(month))
.filter(ticks::Column::Day.eq(day))
.all(db)
.await
.map_err(Error::from)?;
for tick in ticks.clone() {
tick.clone().delete(db).await.map_err(Error::from)?;
if let Err(err) = Update::tick_cancelled(tick).send(tx) {
warn!(err = as_debug!(err); "error sending updates to subscribed channels");
}
}
Ok(Right(Json(ticks)))
}

View file

@ -4,7 +4,10 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::broadcast::Sender;
use crate::{entities::ticks, error::Result};
use crate::{
entities::{ticks, tracks},
error::Result,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Update {
@ -16,6 +19,10 @@ pub enum Update {
kind: UpdateType,
count: u64,
},
TrackChanged {
kind: UpdateType,
track: tracks::Model,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -23,6 +30,9 @@ pub enum Update {
pub enum UpdateType {
TickAdded,
TickDropped,
TrackAdded,
TrackChanged,
TrackDropped,
Error,
}
@ -48,6 +58,26 @@ impl Update {
}
}
pub fn track_added(track: tracks::Model) -> Self {
Self::TrackChanged {
kind: UpdateType::TrackAdded,
track,
}
}
pub fn track_removed(track: tracks::Model) -> Self {
Self::TrackChanged {
kind: UpdateType::TrackDropped,
track,
}
}
pub fn track_changed(track: tracks::Model) -> Self {
Self::TrackChanged {
kind: UpdateType::TrackChanged,
track,
}
}
pub fn to_event(&self) -> Event {
use Update::*;
match self {
@ -56,12 +86,19 @@ impl Update {
Event::json(&json! {{"message": "error: lagged", "count": count}})
.event(format!("{kind:?}"))
}
TrackChanged { kind, track } => Event::json(track).event(format!("{kind:?}")),
}
}
pub fn send(self, tx: &Sender<Self>) -> Result<()> {
let count = tx.send(self.clone())?;
trace!(sent_to = count, update = as_serde!(self); "sent update to SSE channel");
let receiver_count = tx.receiver_count();
if receiver_count > 0 {
trace!(receiver_count = receiver_count, update = as_serde!(self); "sending update");
let count = tx.send(self.clone())?;
trace!(count = count; "update sent");
} else {
trace!("no update receivers, skipping message");
}
Ok(())
}
}

View file

@ -1,11 +1,15 @@
use crate::migrator::Migrator;
use sea_orm_migration::MigratorTrait;
use sea_orm_migration::SchemaManager;
use std::{
default::default,
env,
ffi::{OsStr, OsString},
fs::File,
io::Read,
};
use sea_orm::{Database, DatabaseConnection};
// from https://doc.rust-lang.org/std/ffi/struct.OsString.html
fn concat_os_strings(a: &OsStr, b: &OsStr) -> OsString {
let mut ret = OsString::with_capacity(a.len() + b.len()); // This will allocate
@ -30,7 +34,7 @@ fn get_env_var_or_file<A: AsRef<OsStr>>(key: A) -> Option<String> {
if let Some(path) = env::var_os(file_key) {
// open the file and read it
let mut file = File::open(&path).unwrap_or_else(|_| panic!("no such file at {path:?}"));
let mut val: String = default();
let mut val = String::new();
file.read_to_string(&mut val)
.unwrap_or_else(|_| panic!("reading file at {path:?}"));
Some(val)
@ -58,3 +62,31 @@ pub fn connection_url() -> String {
.unwrap_or(5432_u16);
format!("postgres://{user}:{password}@{host}:{port}/{db}")
}
pub async fn connection() -> DatabaseConnection {
Database::connect(connection_url())
.await
.expect("db connection")
}
pub async fn migrated() -> DatabaseConnection {
let db = connection().await;
let schema_manager = SchemaManager::new(&db);
Migrator::refresh(&db).await.expect("migration");
assert!(schema_manager
.has_table("tracks")
.await
.expect("fetch tracks table"));
assert!(schema_manager
.has_table("ticks")
.await
.expect("fetch ticks table"));
assert!(schema_manager
.has_table("groups")
.await
.expect("fetch groups table"));
assert!(schema_manager
.has_table("track2_groups")
.await
.expect("fetch track2groups table"));
db
}

View file

@ -6,3 +6,5 @@ pub mod groups;
pub mod ticks;
pub mod track2_groups;
pub mod tracks;
pub mod user_tracks;
pub mod users;

View file

@ -4,3 +4,5 @@ pub use super::groups::Entity as Groups;
pub use super::ticks::Entity as Ticks;
pub use super::track2_groups::Entity as Track2Groups;
pub use super::tracks::Entity as Tracks;
pub use super::user_tracks::Entity as UserTracks;
pub use super::users::Entity as Users;

View file

@ -1,6 +1,6 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
use std::default::default;
use std::default::Default;
use chrono::{Datelike, Timelike, Utc};
use sea_orm::entity::prelude::*;
@ -60,7 +60,7 @@ impl ActiveModel {
minute: Set(now.minute().try_into().ok()),
second: Set(now.second().try_into().ok()),
has_time_info: Set(Some(1)),
..default()
..Default::default()
}
}
pub(crate) fn on(date: Date, track_id: i32) -> Self {
@ -80,7 +80,7 @@ impl ActiveModel {
minute: Set(now.minute().try_into().ok()),
second: Set(now.second().try_into().ok()),
has_time_info: Set(Some(1)),
..default()
..Default::default()
}
}
}

View file

@ -24,6 +24,8 @@ pub enum Relation {
Ticks,
#[sea_orm(has_many = "super::track2_groups::Entity")]
Track2Groups,
#[sea_orm(has_many = "super::user_tracks::Entity")]
UserTracks,
}
impl Related<super::ticks::Entity> for Entity {
@ -38,4 +40,19 @@ impl Related<super::track2_groups::Entity> for Entity {
}
}
impl Related<super::user_tracks::Entity> for Entity {
fn to() -> RelationDef {
Relation::UserTracks.def()
}
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
super::user_tracks::Relation::Users.def()
}
fn via() -> Option<RelationDef> {
Some(super::user_tracks::Relation::Tracks.def().rev())
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,46 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "user_tracks")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub user_id: i32,
pub track_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::tracks::Entity",
from = "Column::TrackId",
to = "super::tracks::Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
)]
Tracks,
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UserId",
to = "super::users::Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
)]
Users,
}
impl Related<super::tracks::Entity> for Entity {
fn to() -> RelationDef {
Relation::Tracks.def()
}
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,99 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
use std::default::Default;
use bcrypt::*;
// TODO Add option for argon2 https://docs.rs/argon2/latest/argon2/
use either::Either::{self, Left, Right};
use rocket::http::Status;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::{
api::ErrorResponder,
error::{self, Error},
};
use super::tracks;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
#[serde(skip_deserializing)]
pub id: i32,
pub name: String,
pub password_hash: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::user_tracks::Entity")]
UserTracks,
}
impl Related<super::user_tracks::Entity> for Entity {
fn to() -> RelationDef {
Relation::UserTracks.def()
}
}
impl Related<super::tracks::Entity> for Entity {
fn to() -> RelationDef {
super::user_tracks::Relation::Tracks.def()
}
fn via() -> Option<RelationDef> {
Some(super::user_tracks::Relation::Users.def().rev())
}
}
impl ActiveModelBehavior for ActiveModel {}
impl ActiveModel {
pub fn new(name: impl AsRef<str>, password: impl AsRef<str>) -> error::Result<Self> {
use sea_orm::ActiveValue::Set;
let name = Set(name.as_ref().to_string());
let password_hash = Set(hash(password.as_ref(), DEFAULT_COST)?);
Ok(Self {
name,
password_hash,
..Default::default()
})
}
}
impl Model {
pub fn check_password(
self,
password: impl AsRef<[u8]>,
) -> std::result::Result<Self, Either<Status, ErrorResponder>> {
match verify(password, &self.password_hash) {
Ok(true) => Ok(self),
Ok(false) => Err(Left(Status::Unauthorized)),
Err(err) => Err(Right(Error::from(err).into())),
}
}
pub async fn authorized_track(
&self,
track_id: i32,
db: &DatabaseConnection,
) -> Option<tracks::Model> {
self.find_related(super::prelude::Tracks)
.filter(tracks::Column::Id.eq(track_id))
.one(db)
.await
.ok()
.flatten()
}
pub async fn is_authorized_for(&self, track_id: i32, db: &DatabaseConnection) -> bool {
self.authorized_track(track_id, db).await.is_some()
}
pub async fn authorized_tracks(&self, db: &DatabaseConnection) -> Vec<tracks::Model> {
self.find_related(super::prelude::Tracks)
.all(db)
.await
.unwrap_or_default()
}
}

View file

@ -1,5 +1,6 @@
use std::string;
use bcrypt::BcryptError;
use derive_builder::UninitializedFieldError;
#[derive(Debug, thiserror::Error)]
@ -18,6 +19,10 @@ pub enum Error {
Utf8(#[from] string::FromUtf8Error),
#[error(transparent)]
ChannelSendError(#[from] tokio::sync::broadcast::error::SendError<crate::api::update::Update>),
#[error(transparent)]
Bcrypt(#[from] BcryptError),
#[error(transparent)]
SerdeJson(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, Error>;

8
server/src/lib.rs Normal file
View file

@ -0,0 +1,8 @@
#![feature(proc_macro_hygiene, decl_macro, never_type)]
#[macro_use]
extern crate rocket;
pub mod api;
pub mod db;
pub mod entities;
pub mod error;
mod migrator;

View file

@ -1,4 +1,4 @@
#![feature(default_free_fn, proc_macro_hygiene, decl_macro)]
#![feature(proc_macro_hygiene, decl_macro, never_type)]
#[macro_use]
extern crate rocket;
mod api;
@ -6,32 +6,9 @@ mod db;
mod entities;
mod error;
mod migrator;
use crate::migrator::Migrator;
use sea_orm::Database;
use sea_orm_migration::prelude::*;
#[launch]
async fn rocket_defines_the_main_fn() -> _ {
femme::with_level(femme::LevelFilter::Debug);
let url = db::connection_url();
let db = Database::connect(url).await.expect("db connection");
let schema_manager = SchemaManager::new(&db);
Migrator::refresh(&db).await.expect("migration");
assert!(schema_manager
.has_table("tracks")
.await
.expect("fetch tracks table"));
assert!(schema_manager
.has_table("ticks")
.await
.expect("fetch ticks table"));
assert!(schema_manager
.has_table("groups")
.await
.expect("fetch groups table"));
assert!(schema_manager
.has_table("track2_groups")
.await
.expect("fetch track2groups table"));
api::start_server(db)
femme::with_level(femme::LevelFilter::Trace);
api::start_server(db::migrated().await)
}

View file

@ -0,0 +1,42 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Users::Table)
.if_not_exists()
.col(
ColumnDef::new(Users::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Users::Name).string().unique_key().not_null())
.col(ColumnDef::new(Users::PasswordHash).string().not_null())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Users::Table).to_owned())
.await
}
}
/// Learn more at https://docs.rs/sea-query#iden
#[derive(Iden)]
pub(crate) enum Users {
Table,
Id,
Name,
PasswordHash,
}

View file

@ -0,0 +1,57 @@
use super::{
m20230606_000001_create_tracks_table::Tracks, m20230626_083036_create_users_table::Users,
};
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(UserTracks::Table)
.if_not_exists()
.col(
ColumnDef::new(UserTracks::Id)
.integer()
.not_null()
.primary_key()
.auto_increment(),
)
.col(ColumnDef::new(UserTracks::UserId).integer().not_null())
.col(ColumnDef::new(UserTracks::TrackId).integer().not_null())
.foreign_key(
ForeignKey::create()
.name("fk-user_tracks-user_id")
.from(UserTracks::Table, UserTracks::UserId)
.to(Users::Table, Users::Id),
)
.foreign_key(
ForeignKey::create()
.name("fk-user_tracks-track_id")
.from(UserTracks::Table, UserTracks::TrackId)
.to(Tracks::Table, Tracks::Id),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(UserTracks::Table).to_owned())
.await
}
}
/// Learn more at https://docs.rs/sea-query#iden
#[derive(Iden)]
enum UserTracks {
Table,
Id,
UserId,
TrackId,
}

View file

@ -2,6 +2,8 @@ mod m20230606_000001_create_tracks_table;
mod m20230606_000002_create_ticks_table;
mod m20230606_000003_create_groups_table;
mod m20230606_000004_create_track2groups_table;
mod m20230626_083036_create_users_table;
mod m20230626_150551_associate_users_and_tracks;
use sea_orm_migration::prelude::*;
@ -15,6 +17,8 @@ impl MigratorTrait for Migrator {
Box::new(m20230606_000002_create_ticks_table::Migration),
Box::new(m20230606_000003_create_groups_table::Migration),
Box::new(m20230606_000004_create_track2groups_table::Migration),
Box::new(m20230626_083036_create_users_table::Migration),
Box::new(m20230626_150551_associate_users_and_tracks::Migration),
]
}
}

17
shell.nix Normal file
View file

@ -0,0 +1,17 @@
# DEVELOPMENT shell environment
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
name = "kalkutago";
nativeBuildInputs = with pkgs.buildPackages; [
clang
yarn nodejs
openssl
python3
python3Packages.requests
python3Packages.ipython
rustup
docker
gnumake
];
}

61
test.py Normal file
View file

@ -0,0 +1,61 @@
#!/usr/bin/env python3
#
# Quick script to test endpoints of kalkutago
from requests import get, post, put, patch
from time import gmtime as utc
credentials = {"name": "testuser", "password": "testpass"}
track = {"name": "test", "description": "test track", "icon": "", "enabled": 1}
def test_auth(method):
res = method(f'http://kalkutago/api/v1/auth', json=credentials)
assert 'user' in res.cookies.iterkeys(), \
f'no user cookie found. Cookies: {res.cookies.get_dict()}; body: ' + \
res.text
return res.cookies['user']
def test_create_user():
return test_auth(post)
def test_login():
return test_auth(put)
def test_track_creation(auth_cookie):
res = post('http://kalkutago/api/v1/tracks', json=track,
cookies={'user': auth_cookie})
print(res.text)
res.raise_for_status()
return res.json()
def test_get_track(auth_cookie, track):
res = get(f'http://kalkutago/api/v1/tracks/{track["id"]}',
cookies={'user': auth_cookie})
print(res.text)
res.raise_for_status()
retrieved = res.json()
assert track == retrieved, f'expected {track!r} to equal {retrieved!r}'
return retrieved
def test_tick(auth_cookie, track):
res = patch(f'http://kalkutago/api/v1/tracks/{track["id"]}/ticked',
cookies={'user': auth_cookie})
print(res.text)
res.raise_for_status()
retrieved = res.json()
# result:
# {"id":1,"track_id":6,"year":2023,"month":8,"day":10,"hour":13,"minute":7,"second":41,"has_time_info":1}
now = utc()
assert retrieved['track_id'] == track['id']
assert retrieved['year'] == now.tm_year
assert retrieved['month'] == now.tm_mon
assert retrieved['day'] == now.tm_mday
return retrieved
if __name__ == "__main__":
login_cookie = test_create_user()
test_login()
track = test_track_creation(login_cookie)
retrieved = test_get_track(login_cookie, track)
tick = test_tick(login_cookie, track)