Compare commits

...

3 Commits

Author SHA1 Message Date
Patrick 888e35f839 prepare for production 2025-04-20 22:35:46 +02:00
Patrick e18adab888 updated types a bit 2025-04-20 22:35:12 +02:00
Patrick d77bbb317d use env for data and tmp directories 2025-04-20 21:52:59 +02:00
14 changed files with 188 additions and 57 deletions

16
.dockerignore Normal file
View File

@ -0,0 +1,16 @@
node_modules
Dockerfile*
.dockerignore
.git
.gitignore
README.md
LICENSE
databases
docs
documents
pdfgen
!pdfgen/template-*
scripts
tmp-user-data
user-data

2
.gitignore vendored
View File

@ -33,3 +33,5 @@ pdfgen/*
!pdfgen/template*.tex !pdfgen/template*.tex
documents/* documents/*
user-data/*
tmp-user-data/*

49
Dockerfile Normal file
View File

@ -0,0 +1,49 @@
FROM oven/bun:alpine AS base
WORKDIR /usr/src/app
ENV NODE_ENV=production
ENV APP_USER_DATA_PATH="/app-data/user-data"
ENV APP_TMP_USER_DATA_PATH="/app-data/tmp"
## Stage 1: Install dependencies
FROM base AS install
# install development dependencies
RUN mkdir -p /temp/dev
COPY package.json bun.lockb /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile
# install production dependencies
RUN mkdir -p /temp/prod
COPY package.json bun.lockb /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production
## Stage 2: Build app
FROM base AS prerelease
# copy dependencies into workdir
COPY --from=install /temp/dev/node_modules node_modules
# add project files
COPY . .
# build project
RUN bun run build
## Stage 3: Put everything together
FROM base AS release
# compose the final image
COPY --from=install /temp/prod/node_modules node_modules
COPY --from=prerelease /usr/src/app/build .
#COPY --from=prerelease /usr/src/app/package.json .
RUN mkdir /app-data && chown bun:bun /app-data
VOLUME ["/app-data"]
USER bun
EXPOSE 3000/tcp
ENTRYPOINT [ "bun", "--bun", "run", "start" ]

BIN
bun.lockb

Binary file not shown.

View File

@ -0,0 +1,3 @@
services:
application:
build: .

15
docker-compose.yml Normal file
View File

@ -0,0 +1,15 @@
name: Stundenaufzeichnung
volumes:
user-data:
services:
application:
build: https://git.maschek.info/patrick/stundenaufzeichnung.git#main
ports:
- 3000:3000
volumes:
- type: volume
source: user-data
target: /app-data

View File

@ -6,6 +6,7 @@
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.20.4", "@sveltejs/kit": "^2.20.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/bun": "^1.2.10",
"@types/sqlite3": "^3.1.11", "@types/sqlite3": "^3.1.11",
"svelte": "^5.25.6", "svelte": "^5.25.6",
"svelte-adapter-bun": "^0.5.2", "svelte-adapter-bun": "^0.5.2",

7
src/app.d.ts vendored
View File

@ -1,9 +1,14 @@
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
import type { User } from "$lib/server/database";
// for information about these interfaces // for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} interface Locals {
user: User
}
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}

View File

@ -1,11 +1,21 @@
import type { Handle } from "@sveltejs/kit";
import { error } from "@sveltejs/kit";
import { User, UserDB, init_db, close_db, create_user, get_user, get_user_db } from "$lib/server/database"; import { init_db, close_db, get_user } from "$lib/server/database";
import { Database } from "bun:sqlite"; async function init() {
function init() { if (process.env.APP_USER_DATA_PATH == null) {
console.log("APP_USER_DATA_PATH is not defined. Exiting.");
process.exit(-1);
}
init_db(); if (process.env.APP_TMP_USER_DATA_PATH == null) {
console.log("APP_TMP_USER_DATA_PATH is not defined. Exiting.");
process.exit(-1);
}
await init_db();
console.log("started"); console.log("started");
@ -16,24 +26,27 @@ function deinit() {
console.log('exit'); console.log('exit');
} }
init(); await init();
process.on('exit', (reason) => { process.on('exit', (_) => {
deinit(); deinit();
process.exit(0); process.exit(0);
}); });
process.on('SIGINT', (reason) => { process.on('SIGINT', (_) => {
console.log("SIGINT") console.log("SIGINT")
process.exit(0); process.exit(0);
}) })
import { getAllFiles } from "$lib/server/docstore" export let handle: Handle = async function ({ event, resolve }) {
export async function handle({ event, resolve }) { let user = get_user();
event.locals.user = get_user(); if (!user) {
console.log("handle"); return error(404, "No user"); // redirect login
}
event.locals.user = user;
return await resolve(event); return await resolve(event);
} }

View File

@ -1,6 +1,3 @@
import { Database } from 'bun:sqlite';
export interface UserEntry { export interface UserEntry {
id: number; id: number;
gender: string; gender: string;

View File

@ -1,10 +1,10 @@
import { mkdir } from "node:fs/promises"; import fs from "node:fs/promises";
import { Database, SQLiteError } from "bun:sqlite"; import { Database, SQLiteError } from "bun:sqlite";
import { UserEntry, RecordEntry, EstimatesEntry } from "$lib/db_types"; import { UserEntry, RecordEntry, EstimatesEntry } from "$lib/db_types";
import { calculateDuration, parseDate, toInt, isTimeValidHHMM } from "$lib/util"; import { calculateDuration, parseDate, toInt, isTimeValidHHMM } from "$lib/util";
const DATABASES_PATH: string = "./databases/"; const DATABASES_PATH: string = (process.env.APP_USER_DATA_PATH ?? ".") + "/databases/";
const USER_DATABASE_PATH: string = DATABASES_PATH + "users.sqlite"; const USER_DATABASE_PATH: string = DATABASES_PATH + "users.sqlite";
const CHECK_QUERY: string = const CHECK_QUERY: string =
@ -291,7 +291,7 @@ function is_db_initialized(db: Database): boolean {
throw exception; throw exception;
} }
console.log(e); console.log(exception);
return false; return false;
} }
@ -305,8 +305,10 @@ function setup_db(db: Database, setup_queries: string[]) {
setup_queries.forEach((q) => { /*console.log(q);*/ db.query(q).run(); }); setup_queries.forEach((q) => { /*console.log(q);*/ db.query(q).run(); });
} }
export function init_db() { export async function init_db() {
const stdout = await fs.mkdir(DATABASES_PATH, { recursive: true });
console.log(stdout)
user_database = new Database(USER_DATABASE_PATH, { strict: true, create: true }); user_database = new Database(USER_DATABASE_PATH, { strict: true, create: true });
if (!is_db_initialized(user_database)) { if (!is_db_initialized(user_database)) {
@ -367,6 +369,8 @@ export function get_user(): User | null {
try { try {
fs.mkdir(DATABASES_PATH, { recursive: true });
let userdb = new Database(db_name, { create: true, strict: true }); let userdb = new Database(db_name, { create: true, strict: true });
if (!is_db_initialized(userdb)) { if (!is_db_initialized(userdb)) {

View File

@ -1,21 +1,25 @@
import b from "bun"; import b from "bun";
import fs from "node:fs/promises" import fs from "node:fs/promises"
import type { RecordEntry } from "$lib/db_types";
import { User } from "$lib/server/database"; import { User } from "$lib/server/database";
import { MONTHS, toInt, padInt, parseDate, calculateDuration, isoToLocalDate, month_of, weekday_of } from "$lib/util" import { MONTHS, toInt, padInt, parseDate, calculateDuration, isoToLocalDate, month_of, weekday_of } from "$lib/util"
export class UserBusyException extends Error { export class UserBusyException extends Error {
constructor(message, userid) { userid: number
super(`user (id: ${userid}) is busy: ${message}`);
this.name = this.constructor.name; constructor(message: string, userid: number) {
this.userid = userid; super(`user (id: ${userid}) is busy: ${message}`);
} this.name = this.constructor.name;
this.userid = userid;
}
} }
export class LatexException extends Error { export class LatexException extends Error {
constructor(message, userid, errno, stdout) { errno: number
stdout: string
constructor(message: string, userid: number, errno: number, stdout: string) {
super(message + ` (userid: ${userid}, errno: ${errno})\n${stdout}`); super(message + ` (userid: ${userid}, errno: ${errno})\n${stdout}`);
this.name = this.constructor.name; this.name = this.constructor.name;
this.errno = errno; this.errno = errno;
@ -24,107 +28,129 @@ export class LatexException extends Error {
} }
export class FileExistsException extends Error { export class FileExistsException extends Error {
constructor(path) { path: string
constructor(path: string) {
super("File already exists: " + path); super("File already exists: " + path);
this.name = this.constructor.name; this.name = this.constructor.name;
this.path = path; this.path = path;
} }
} }
export interface FileProperties {
identifier: string
path: string
name: string
cdate: Date
}
const DOCUMENTS_PATH: string = "./documents"
const DOCUMENTS_PATH: string = (process.env.APP_USER_DATA_PATH ?? ".") + "/documents"
const ESTIMATES_FOLD: string = "estimates" const ESTIMATES_FOLD: string = "estimates"
const RECORDS_FOLD: string = "records" const RECORDS_FOLD: string = "records"
const GENERATION_PATH: string = "./pdfgen"; const GENERATION_PATH: string = (process.env.APP_TMP_USER_DATA_PATH ?? ".") + "/pdfgen";
const TEMPLATE_REC = "template-rec.tex"; const TEMPLATE_REC = "template-rec.tex";
const TEMPLATE_REC_PATH = `${GENERATION_PATH}/${TEMPLATE_REC}`; const TEMPLATE_REC_PATH = `./templates/${TEMPLATE_REC}`;
const TEMPLATE_EST = "template-est.tex"; const TEMPLATE_EST = "template-est.tex";
const TEMPLATE_EST_PATH = `${GENERATION_PATH}/${TEMPLATE_EST}`; const TEMPLATE_EST_PATH = `./templates/${TEMPLATE_EST}`;
const SALUTATION = { const SALUTATION: Record<string, string> = {
m: "Herrn", m: "Herrn",
w: "Frau" w: "Frau"
} }
const GENDER_END = { const GENDER_END: Record<string, string> = {
m: "", m: "",
w: "in", w: "in",
} }
/*
const SALUTATION: Map<string, string> = new Map(Object.entries({
m: "Herrn",
w: "Frau"
}))
const GENDER_END: Map<string, string> = new Map(Object.entries({
"m": "",
"w": "in",
}))
*/
// this may be a race condition??? // this may be a race condition???
// is a mutex needed in JS? // is a mutex needed in JS?
let locked_users: number[] = new Set(); let locked_users: Set<Number> = new Set();
export async function getAllFiles(user: User) { export async function getAllFiles(user: User) {
const path = `${DOCUMENTS_PATH}/user-${user.id}`; const path = `${DOCUMENTS_PATH}/user-${user.id}`;
let file_names = (await fs.readdir(path, { recursive: true }).catch((err) => { let file_names = (await fs.readdir(path, { recursive: true }).catch((_) => {
return []; return [];
})).filter((v) => { })).filter((v) => {
return !(v == ESTIMATES_FOLD || v == RECORDS_FOLD); return !(v == ESTIMATES_FOLD || v == RECORDS_FOLD);
}); });
let files = (await Promise.all(file_names.map(async (v) => { let files: FileProperties[] = (await Promise.all(file_names.map(async (v) => {
return { return {
identifier: v.split("/").pop(),
path: v, path: v,
name: v.split("/").pop(), name: v.split("/").pop(),
timestamp: (await fs.stat(`${path}/${v}`)).mtime cdate: (await fs.stat(`${path}/${v}`)).ctime
} } as FileProperties;
}))).sort((a, b) => { }))).sort((a, b) => {
return a.timestamp - b.timestamp return a.cdate.getTime() - b.cdate.getTime()
}); });
return files; return files;
} }
export async function getRecordFiles(user: User) { export async function getRecordFiles(user: User): Promise<FileProperties[]> {
const path = `${DOCUMENTS_PATH}/user-${user.id}/${RECORDS_FOLD}`; const path = `${DOCUMENTS_PATH}/user-${user.id}/${RECORDS_FOLD}`;
let file_names = await fs.readdir(path).catch((err) => { console.log(err); return [] }); let file_names = await fs.readdir(path).catch((err) => { console.log(err); return [] });
let files = await Promise.all(file_names.map(async (v) => { let files: FileProperties[] = await Promise.all(file_names.map(async (v) => {
return { return {
identifier: v.replace(/^Stundenliste-/, "").replace(/\.pdf$/, ""), identifier: v.replace(/^Stundenliste-/, "").replace(/\.pdf$/, ""),
filename: v, name: v,
path: `${RECORDS_FOLD}/${v}`, path: `${RECORDS_FOLD}/${v}`,
cdate: (await fs.stat(`${path}/${v}`)).ctime, cdate: (await fs.stat(`${path}/${v}`)).ctime,
} } as FileProperties;
})) }))
return files; return files;
} }
export async function getEstimateFiles(user: User) { export async function getEstimateFiles(user: User): Promise<FileProperties[]> {
const path = `${DOCUMENTS_PATH}/user-${user.id}/${ESTIMATES_FOLD}`; const path = `${DOCUMENTS_PATH}/user-${user.id}/${ESTIMATES_FOLD}`;
let file_names = await fs.readdir(path).catch((err) => { console.log(err); return [] }); let file_names = await fs.readdir(path).catch((err) => { console.log(err); return [] });
let files = await Promise.all(file_names.map(async (v) => { let files: FileProperties[] = await Promise.all(file_names.map(async (v) => {
return { return {
identifier: v.match(/\d.Quartal_\d\d\d\d/)[0].replace(/.Quartal_/, ".").split(".").reverse().join("-"), identifier: v.match(/\d.Quartal_\d\d\d\d/)?.[0].replace(/.Quartal_/, ".").split(".").reverse().join("-") ?? "",
filename: v, name: v,
path: `${ESTIMATES_FOLD}/${v}`, path: `${ESTIMATES_FOLD}/${v}`,
cdate: (await fs.stat(`${path}/${v}`)).ctime, cdate: (await fs.stat(`${path}/${v}`)).ctime,
} } as FileProperties;
})) }))
return files; return files;
} }
export async function getFile(user: User, filename: string) { export async function getFile(user: User, filename: string): Promise<Uint8Array> {
const path = `${DOCUMENTS_PATH}/user-${user.id}`; const path = `${DOCUMENTS_PATH}/user-${user.id}`;
let file = Bun.file(`${path}/${filename}`) let file = Bun.file(`${path}/${filename}`)
if (!await file.exists()) { if (!await file.exists()) {
return null; return new Promise(_ => null);
} }
return await file.bytes(); return file.bytes();
} }
export async function generateEstimatePDF(user: User, year: number, quarter: number) { export async function generateEstimatePDF(user: User, year: number, quarter: number) {
@ -134,10 +160,10 @@ export async function generateRecordPDF(user: User, year: number, quarter: numbe
return await _runProtocted(_generateRecordPDF, user, year, quarter); return await _runProtocted(_generateRecordPDF, user, year, quarter);
} }
async function _runProtocted(f, user: User, ...args) { async function _runProtocted(f: Function, user: User, ...args: any[]) {
if (locked_users.has(user.id)) { if (locked_users.has(user.id)) {
throw UserBusyException(`Cannot generate pdf (type: ${type})` ,user.id); throw new UserBusyException(`Cannot generate pdf for user: ` ,user.id);
} }
locked_users.add(user.id); locked_users.add(user.id);
@ -266,7 +292,7 @@ async function _genLatexEst(user: User, file_pref: string, year: number, quarter
csvfilewriter.write(`${SALUTATION[user.gender]} ${user.name};${user.address};Arbeitnehmer${GENDER_END[user.gender]};Teilzeitmitarbeiter${GENDER_END[user.gender]};${isoToLocalDate(new Date().toISOString())}\n`) csvfilewriter.write(`${SALUTATION[user.gender]} ${user.name};${user.address};Arbeitnehmer${GENDER_END[user.gender]};Teilzeitmitarbeiter${GENDER_END[user.gender]};${isoToLocalDate(new Date().toISOString())}\n`)
for (let i = 0; i < 3; ++i) { for (let i = 0; i < 3; ++i) {
csvfilewriter.write(`${MONTHS[(quarter - 1) * 3 + i]} ${year};${estimates[`estimate_${i}`]}\n`); csvfilewriter.write(`${MONTHS[(quarter - 1) * 3 + i]} ${year};${(estimates as any)[`estimate_${i}`]}\n`);
} }
csvfilewriter.end(); csvfilewriter.end();
@ -274,7 +300,7 @@ async function _genLatexEst(user: User, file_pref: string, year: number, quarter
const { stdout, exitCode } = await b.$`pdflatex -interaction=nonstopmode -halt-on-error -jobname=${file_pref} -output-format=pdf ${TEMPLATE_EST}`.cwd(dir).quiet().nothrow(); const { stdout, exitCode } = await b.$`pdflatex -interaction=nonstopmode -halt-on-error -jobname=${file_pref} -output-format=pdf ${TEMPLATE_EST}`.cwd(dir).quiet().nothrow();
if (exitCode != 0) { if (exitCode != 0) {
throw new LatexException("Failed to create estimate PDF", user.id, exitCode, stdout); throw new LatexException("Failed to create estimate PDF", user.id, exitCode, stdout.toString());
} }
return `${dir}/${file_pref}.pdf`; return `${dir}/${file_pref}.pdf`;