62 Commits

Author SHA1 Message Date
kasun 7b21011f5a updated README.MD 2026-04-11 14:49:22 +02:00
kasun 01fa3e4267 added integration test for missing creds 2026-04-09 21:18:52 +02:00
kasun a1ebb1be21 format 2026-04-09 21:18:40 +02:00
kasun 7de38afec2 added ConnectionService and HomeboxProvider tests 2026-04-09 21:11:17 +02:00
kasun a5c1931a2a removed apikey variable from homebox test 2026-04-09 21:09:27 +02:00
kasun 3e80b81e07 added comment 2026-04-08 22:35:59 +02:00
kasun ef4ee70aac added WrongServiceTypeException 2026-04-08 22:33:04 +02:00
kasun 00bb929f22 format 2026-04-08 22:32:06 +02:00
kasun c553735653 removed main test 2026-04-08 22:31:36 +02:00
kasun 680e5f0abd revised exception handling for empty fields. 2026-04-08 20:57:27 +02:00
kasun 2000278f1a got rid if Map and added username, password and apikey to request body 2026-04-08 20:51:14 +02:00
kasun 702e6bb973 revised HBTest to match new request body 2026-04-08 20:49:28 +02:00
kasun 8a5f7c2bf8 added username to ConnectionEntity 2026-04-08 20:48:21 +02:00
kasun 3e4a1f92b1 added username for uniqueness of connection db entry 2026-04-08 17:38:42 +02:00
kasun c59f2598b0 added final fileds 2026-04-08 02:22:07 +02:00
kasun 30784fa756 format 2026-04-08 02:21:55 +02:00
kasun da0411f5d1 corrected typo 2026-04-06 11:51:03 +02:00
kasun 240a366ce8 added login logic excl refresh call 2026-04-06 05:02:46 +02:00
kasun 9c3e1469c7 added ApplicationTests 2026-04-06 03:51:36 +02:00
kasun ba7887f6b2 updated spring boot version 2026-04-06 02:09:58 +02:00
kasun 8b1a604dc2 renamed enum classes 2026-04-06 01:28:31 +02:00
kasun be0821b0be replaced constructor with requiredArgsConstructor annotation 2026-04-04 23:15:38 +02:00
kasun 913f3c75f1 made improvements 2026-04-03 21:28:00 +02:00
kasun 0169cf04b6 refactored connection classes to be more generic and accept credentials of different apps. 2026-04-03 02:58:34 +02:00
kasun ab1d7e68f5 added rest path 2026-04-02 20:28:34 +02:00
kasun 2387d41ebb changed format 2026-03-30 22:43:07 +02:00
kasun c15c7a0d61 added ErrorMessages enum 2026-03-30 22:41:41 +02:00
kasun bda9391c75 changed format 2026-03-30 21:30:19 +02:00
kasun 6267e18478 changed format 2026-03-30 20:42:48 +02:00
kasun 79379b238a changes test name added comment 2026-03-30 20:42:39 +02:00
kasun 75b6995b94 added post request to achieve login response with tokens 2026-03-30 05:07:51 +02:00
kasun 8128ab829f added additional .env.local file import pathes 2026-03-30 05:05:57 +02:00
kasun 8da3b14e40 added wiremock dependency for remote api testing 2026-03-30 05:05:24 +02:00
kasun 55508a27bb Merge pull request 'added api client setup doc' (#28) from setup-Hoppscotch-instance into main
Reviewed-on: #28
2026-03-27 13:21:22 +01:00
kasun 892cbdacd2 added api client setup doc 2026-03-27 13:21:01 +01:00
kasun c311e26a77 Merge pull request 'added litellm setup doc' (#26) from setup-litell-instance into main
Reviewed-on: #26
2026-03-27 03:32:37 +01:00
kasun ae10480ed5 added litellm setup doc 2026-03-27 03:31:55 +01:00
kasun 0c13b134ab renamed env file 2026-03-26 21:25:51 +01:00
kasun 84fe628656 Merge pull request 'establish-testing-strategy' (#25) from establish-testing-strategy into main
Reviewed-on: #25
2026-03-26 21:14:16 +01:00
kasun f7cf4360c0 added frontend test environment 2026-03-26 21:13:04 +01:00
kasun 9228e335e4 updated vite gitignore 2026-03-25 02:42:36 +01:00
kasun e0739cf89a revised spring boot docs to match testing strategy 2026-03-25 02:37:37 +01:00
kasun 8e48941870 made gradlew executable 2026-03-25 02:03:00 +01:00
kasun f20221fd8d changed ddl-auto of main db 2026-03-25 01:54:26 +01:00
kasun 3f579799af setup testing environment with test-db, added test 2026-03-25 01:53:42 +01:00
kasun dae8b21c77 revised code-server config dock 2026-03-25 00:33:23 +01:00
kasun 2b93165395 replaced frontend port with vite port 2026-03-25 00:31:14 +01:00
kasun dfcb6cc2c1 remove compose.yaml 2026-03-25 00:30:42 +01:00
kasun 99fe65fb57 removed docker compose dependency and added dedicated pgvector db 2026-03-25 00:30:21 +01:00
kasun dd7d468063 fixed format 2026-03-24 04:27:21 +01:00
kasun 94d77e8843 Merge pull request 'setup-frontend-environment' (#21) from setup-frontend-environment into main
Reviewed-on: #21
2026-03-24 04:21:46 +01:00
kasun 89b4a297fe added frontend config doc 2026-03-24 04:20:55 +01:00
kasun 1aa413022f revised necessary packages for vite 2026-03-24 04:08:01 +01:00
kasun fd91198315 added server config for local development 2026-03-24 03:57:24 +01:00
kasun 6df798c403 added initial vite project 2026-03-24 03:56:47 +01:00
kasun 6d9942d532 changed frontend framework from next.js to vite 2026-03-24 03:25:55 +01:00
kasun 0c88e3fd5a Merge pull request 'setup-spring-boot' (#20) from setup-spring-boot into main
Reviewed-on: #20
2026-03-23 23:49:10 +01:00
kasun fcdc1d0b50 added doc for spring boot setup 2026-03-23 23:46:33 +01:00
kasun 95c3362f0f commented out spring security dependency for now 2026-03-23 23:46:17 +01:00
kasun 66e1a32ade fixed typo 2026-03-20 13:54:29 +01:00
kasun 71d5f23e5f revised code-server doc 2026-03-20 02:24:13 +01:00
kasun 15831aefd7 added initial spring boot setup with external postgres connection and openai api key 2026-03-20 01:50:17 +01:00
63 changed files with 7069 additions and 44 deletions
+86
View File
@@ -0,0 +1,86 @@
## Project Intent
Vaessl is a **technical playground** and proof-of-concept designed to showcase a complete, professional-grade software lifecycle. The primary goal is not just to build a functional utility, but to demonstrate a multi-experience approach to full-stack engineering, DevOps, and AI integration.
This project serves as a transparent portfolio of my take on:
* **Infrastructure:** Moving from local-machine to browser-based development environment hosted on my Proxmox server.
* **Modern AI Orchestration:** Building a system that actually uses AI to do heavy lifting, like identifying objects and understanding natural language using Spring AI and LiteLLM.
* **Architectural Foresight:** Designing with abstraction layers early to ensure the system can scale from a single-target tool to a multi-app bridge without significant refactoring.
---
## What is Vaessl?
Vaessl acts as translator between user inputs via text or image and digital management systems. It performs image and semantic search analysis before serving results from applications (e.g., Homebox, WikiJS) via REST API.
### Core Functionality
* **Staging:** Raw images and metadata are stored in a PostgreSQL staging table.
* **AI-Powered Analysis:** The system utilizes **LiteLLM** as a unified gateway to process inputs via LLM of choice.
* **Semantic Discovery:** By utilizing **Spring AI** and **pgvector**, Vaessl stores description embeddings. This allows for intent-based search (e.g., finding a "Wrench" by searching for "tool to fix a leaky pipe").
---
## Technical Stack
| Layer | Technology |
| :--- | :--- |
| **Backend** | Spring Boot 4.0.x, Java 25 (LTS), Spring AI |
| **Frontend** | React (Vite), Tailwind CSS, SCSS |
| **Database** | PostgreSQL + `pgvector` |
| **AI Gateway** | LiteLLM (Unified API proxy) |
| **DevOps** | Docker, Portainer, Hoppscotch (API testing), Gitea Actions, Jenkins |
---
## Infrastructure & Methodology
### 1. Centralized Development (Code-Server)
To maintain a zero-footprint local setup, I develop within a custom **code-server** Docker instance.
* **Custom Image:** The environment is baked with a specific toolchain (OpenJDK 25, Node.js 24) to ensure absolute environment parity.
* **Remote Access:** Secured via Pangolin tunnel, allowing for secure, remote development without exposing ports to the public internet.
### 2. Abstraction & Scalability
A core requirement was to prevent vendor or application lock-in:
* **API Abstraction:** While the initial integration targets **Homebox**, the export logic is decoupled. This allows the bridge to support other platforms like **WikiJS** by simply implementing new mapping profiles.
* **AI Agnostic:** LiteLLM abstracts the AI provider, allowing the backend to switch between cloud APIs and local inference engines (Ollama) without code changes.
### 3. Testing Strategy
* **Database Parity:** I utilize mirrored containers for development and testing (`vaessl-db` vs `vassal-test-db`). This ensures that JPA operations are tested against the real PostgreSQL engine and `pgvector` extensions rather than mocks.
* **Frontend TDD:** A Vitest-based stack provides a fast feedback loop for the UI, ensuring component reliability before integration with the Spring Boot backend.
---
## Roadmap
### Phase 1: Foundation
* [x] Deployment of core infrastructure (PostgreSQL, LiteLLM, Hoppscotch via Docker).
* [x] Custom Code-Server image build and deployment.
* [x] Spring Boot skeleton with Java 25 and Gradle Kotlin DSL.
### Phase 2: The Processing Pipeline (Current)
* [ ] Implementation of Image/Semantic Search $\rightarrow$ AI $\rightarrow$ Staging Table workflow.
* [ ] Development of the data refinement UI in React.
* [ ] Initial API connector for Homebox.
### Phase 3: Semantic Search & Expansion
* [ ] Integration of Spring AI vector embeddings.
* [ ] Implementation of additional API targets (WikiJS).
* [ ] Launch of a public-facing demo.
---
## Setup & Deployment
The project is orchestrated via Docker Compose for high portability.
1. Clone the Repository.
2. Environment Setup: Configure `.env.local` with your database credentials and AI API keys.
```
DB_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl
DB_TEST_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl_test
DB_USERNAME={username}
DB_PASSWORD={password}
OPENAI_KEY={api-key} # or LLM provider of choice
OPENAI_BASE_URL=https://api.openai.com/v1
PG_DRIVER_CLASS_NAME=org.postgresql.Driver
```
3. Setup both production and test pgvector databases using the official pgvector docker image or installing the pgvector extension to you existing PostGreSQL database.
4. Execute "npm i" and build gradle.
+3
View File
@@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
+40
View File
@@ -0,0 +1,40 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### Misc ###
*.local
+53
View File
@@ -0,0 +1,53 @@
plugins {
java
id("org.springframework.boot") version "4.0.5"
id("io.spring.dependency-management") version "1.1.7"
}
group = "com.vaessl"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
repositories {
mavenCentral()
}
extra["springAiVersion"] = "2.0.0-M3"
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.ai:spring-ai-starter-model-openai")
compileOnly("org.projectlombok:lombok")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("org.postgresql:postgresql")
annotationProcessor("org.projectlombok:lombok")
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
// testImplementation("org.springframework.boot:spring-boot-starter-security-test")
testImplementation("org.springframework.boot:spring-boot-starter-validation-test")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.wiremock:wiremock-standalone:3.12.0")}
dependencyManagement {
imports {
mavenBom("org.springframework.ai:spring-ai-bom:${property("springAiVersion")}")
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+1
View File
@@ -0,0 +1 @@
rootProject.name = "app"
@@ -0,0 +1,12 @@
package com.vaessl.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@@ -0,0 +1,24 @@
package com.vaessl.app.connection;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RestController
@RequiredArgsConstructor
public class ConnectionController {
private final ConnectionService connectionService;
@PostMapping("/login")
public ResponseEntity<ConnectionResponse> loginResponse(@Valid @RequestBody ConnectionRequest request) {
return ResponseEntity.ok(connectionService.login(request));
}
}
@@ -0,0 +1,30 @@
package com.vaessl.app.connection;
import jakarta.persistence.DiscriminatorColumn;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Inheritance;
import jakarta.persistence.InheritanceType;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) })
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "service_type")
@Getter
@Setter
@NoArgsConstructor
public abstract class ConnectionEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String appUrl;
private String username;
}
@@ -0,0 +1,19 @@
package com.vaessl.app.connection;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
public interface ConnectionProvider {
void checkCredentials(ConnectionRequest request);
String getServiceType();
ConnectionResponse authenticate(ConnectionRequest request);
ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request);
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
}
@@ -0,0 +1,10 @@
package com.vaessl.app.connection;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ConnectionRepository extends JpaRepository<ConnectionEntity, Long> {
ConnectionEntity findByAppUrlAndUsername(String appUrl, String username);
}
@@ -0,0 +1,49 @@
package com.vaessl.app.connection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import com.vaessl.app.exception.WrongServiceTypeException;
@Service
public class ConnectionService {
private final Map<String, ConnectionProvider> providerRegistry;
private final ConnectionRepository cRepository;
public ConnectionService(List<ConnectionProvider> providers, ConnectionRepository cRepository) {
this.providerRegistry = providers.stream()
.collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p));
this.cRepository = cRepository;
}
public ConnectionResponse login(ConnectionRequest request) {
ConnectionProvider provider = providerRegistry.get(request.serviceType());
if (provider == null) {
throw new WrongServiceTypeException();
}
provider.checkCredentials(request);
ConnectionResponse response = provider.authenticate(request);
ConnectionEntity existing = provider.findUniqueConnectionEntry(request);
if (existing != null) {
provider.updateToRepository(existing, response);
} else {
ConnectionEntity newEntity = provider.connectionToEntity(request, response);
cRepository.save(newEntity);
}
return response;
}
}
@@ -0,0 +1,15 @@
package com.vaessl.app.connection;
public enum Endpoint {
HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login");
private final String value;
private Endpoint(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
@@ -0,0 +1,103 @@
package com.vaessl.app.connection;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import com.vaessl.app.exception.EmptyCredentialsException;
import static com.vaessl.app.connection.Endpoint.*;
@Component
public class HomeBoxConnectionProvider implements ConnectionProvider {
private final RestClient.Builder restClientBuilder;
private final ConnectionRepository cRepository;
public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) {
this.restClientBuilder = restClientBuilder;
this.cRepository = cRepository;
}
@Override
public void checkCredentials(ConnectionRequest request) {
if (request.username() == null || request.password() == null) {
List<String> missingFields = new ArrayList<>();
if (request.username() == null) {
missingFields.add("username");
}
if (request.password() == null) {
missingFields.add("password");
}
throw new EmptyCredentialsException(List.copyOf(missingFields));
}
}
@Override
public String getServiceType() {
return "HOMEBOX";
}
@Override
public ConnectionResponse authenticate(ConnectionRequest request) {
Map<String, Object> homeboxPayload = Map.of("username", request.username(),
"password", request.password(), "stayLoggedIn",
request.stayLoggedIn());
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl())
.build()
.post()
.uri(HOMEBOX_LOGIN.getValue())
.body(homeboxPayload)
.retrieve()
.body(HomeboxLoginResponse.class);
if (hbResponse == null) {
throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl());
}
Map<String, Object> attachmentToken = new HashMap<>();
attachmentToken.put("attachmentToken", hbResponse.attachmentToken());
return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken);
}
@Override
public ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request) {
return cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
}
@Override
public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) {
return HomeboxEntity.from(request, response);
}
@Override
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
if (existing instanceof HomeboxEntity hbE) {
hbE.setToken(response.token());
hbE.setExpiresAt(response.expiresAt());
hbE.setAttachmentToken(response.getExtraVar("attachmentToken"));
cRepository.save(hbE);
}
}
private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) {
}
}
@@ -0,0 +1,35 @@
package com.vaessl.app.connection;
import java.time.Instant;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity;
import lombok.Getter;
import lombok.Setter;
@Entity
@DiscriminatorValue("HOMEBOX")
@Getter
@Setter
public class HomeboxEntity extends ConnectionEntity {
private String token;
private String attachmentToken;
private Instant expiresAt;
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
HomeboxEntity he = new HomeboxEntity();
he.setAppUrl(request.appUrl());
he.setUsername(request.username());
he.setToken(response.token());
he.setAttachmentToken(response.getExtraVar("attachmentToken"));
he.setExpiresAt(response.expiresAt());
return he;
}
}
@@ -0,0 +1,14 @@
package com.vaessl.app.connection;
import lombok.Getter;
@Getter
public enum ServiceType {
HOMEBOX("HOMEBOX");
private final String value;
private ServiceType(String value){
this.value = value;
}
}
@@ -0,0 +1,25 @@
package com.vaessl.app.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
public record ConnectionRequest(
@NotBlank(message = "App URL is mandatory") String appUrl,
@NotBlank(message = "Service type is mandatory") String serviceType,
String username,
String password,
String apiKey,
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
public ConnectionRequest {
if (stayLoggedIn == null) {
stayLoggedIn = false;
}
}
public ConnectionRequest(String appUrl, String serviceType, String username, String password,
Boolean stayLoggedIn) {
this(appUrl, serviceType, username, password, null, stayLoggedIn);
}
}
@@ -0,0 +1,17 @@
package com.vaessl.app.dto;
import java.time.Instant;
import java.util.Map;
public record ConnectionResponse(String token, Instant expiresAt, Map<String, Object> extraResponseData) {
public String getExtraVar(String key) {
if(extraResponseData == null) {
return null;
} else {
Object value = extraResponseData.get(key);
return value != null ? String.valueOf(value) : null;
}
}
}
@@ -0,0 +1,13 @@
package com.vaessl.app.exception;
import java.util.List;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class EmptyCredentialsException extends RuntimeException {
private final List<String> missingFields;
}
@@ -0,0 +1,35 @@
package com.vaessl.app.exception;
import org.springframework.http.HttpStatus;
import com.fasterxml.jackson.annotation.JsonValue;
public enum ErrorMessage {
BAD_REQUEST_EMPTY_FIELDS(HttpStatus.BAD_REQUEST, "Fields must not be empty."), UNAUTHORIZED_WRONG_LOGIN(
HttpStatus.UNAUTHORIZED, "Invalid username or password."), SERVICE_UNAVAILABLE_UNREACHABLE_URL(
HttpStatus.SERVICE_UNAVAILABLE, "The target URL is unreachable."), SERVER_ERROR_GENERAL(
"The external app returned a server error: "), WRONG_SERVICE_TYPE(HttpStatus.NOT_FOUND,
"No such service type.");
private final HttpStatus status;
private final String message;
private ErrorMessage(HttpStatus status, String message) {
this.status = status;
this.message = message;
}
private ErrorMessage(String message) {
this.status = null;
this.message = message;
}
public HttpStatus getStatus() {
return status;
}
@JsonValue
public String getMessage() {
return message;
}
}
@@ -0,0 +1,61 @@
package com.vaessl.app.exception;
import org.springframework.http.ProblemDetail;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import static com.vaessl.app.exception.ErrorMessage.*;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) {
String defaultMessages = e.getBindingResult().getFieldErrors().stream().map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " [" + defaultMessages + "]");
}
@ExceptionHandler(HttpClientErrorException.Unauthorized.class)
public ProblemDetail handleUnauthorizedAccess(HttpClientErrorException e) {
return ProblemDetail.forStatusAndDetail(UNAUTHORIZED_WRONG_LOGIN.getStatus(),
UNAUTHORIZED_WRONG_LOGIN.getMessage());
}
@ExceptionHandler(ResourceAccessException.class)
public ProblemDetail handleNoConnection(ResourceAccessException e) {
return ProblemDetail.forStatusAndDetail(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getStatus(),
SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
}
@ExceptionHandler(HttpServerErrorException.class)
public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) {
return ProblemDetail
.forStatusAndDetail(e.getStatusCode(),
SERVER_ERROR_GENERAL.getMessage() + e.getStatusText());
}
@ExceptionHandler(WrongServiceTypeException.class)
public ProblemDetail handleWrongServiceType(WrongServiceTypeException e) {
return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(), WRONG_SERVICE_TYPE.getMessage());
}
@ExceptionHandler(EmptyCredentialsException.class)
public ProblemDetail handleEmptyCredentials(EmptyCredentialsException e) {
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " " + e.getMissingFields());
}
}
@@ -0,0 +1,4 @@
package com.vaessl.app.exception;
public class WrongServiceTypeException extends RuntimeException {
}
@@ -0,0 +1,27 @@
spring:
application:
name: vaessl
config:
import:
- "optional:file:.env.local[.properties]"
- "optional:file:backend/.env.local[.properties]"
- "optional:file:vaessl/backend/.env.local[.properties]"
datasource:
url : ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
driver-class-name: ${PG_DRIVER_CLASS_NAME}
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
ai:
openai:
base-url: ${OPENAI_BASE_URL}
api-key: ${OPENAI_KEY}
chat:
options:
model: gpt-4o-mini
server:
servlet:
context-path: /api
@@ -0,0 +1,32 @@
package com.vaessl.app;
import java.sql.Connection;
import java.sql.SQLException;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("test")
class ApplicationTests {
@Autowired
private DataSource dataSource;
@Test
void contextLoads() {
}
@Test
void connectionToTestDbWorks() throws SQLException {
try (Connection connection = dataSource.getConnection()) {
assertThat(connection.getMetaData().getURL()).contains("vaessl_test");
}
}
}
@@ -0,0 +1,50 @@
package com.vaessl.app.connection;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.exception.EmptyCredentialsException;
import static com.vaessl.app.connection.Mockdata.*;
@ExtendWith(MockitoExtension.class)
class ConnectionServiceTest {
@Mock
private ConnectionProvider mockProvider;
@Mock
private ConnectionRepository mockRepo;
private ConnectionService connectionService;
@BeforeEach
void setup() {
when(mockProvider.getServiceType()).thenReturn(MOCK_SERVICE_TYPE);
connectionService = new ConnectionService(List.of(mockProvider), mockRepo);
}
@Test
void login_ShouldAbort_WhenCheckCredentialsThrowsException() {
ConnectionRequest request = new ConnectionRequest(MOCK_URL, MOCK_SERVICE_TYPE, null, null, false);
doThrow(new EmptyCredentialsException(List.of("username")))
.when(mockProvider).checkCredentials(request);
assertThrows(EmptyCredentialsException.class, () -> connectionService.login(request));
verify(mockProvider, never()).authenticate(any());
}
}
@@ -0,0 +1,27 @@
package com.vaessl.app.connection;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.exception.EmptyCredentialsException;
import static org.assertj.core.api.Assertions.assertThat;
import static com.vaessl.app.connection.Mockdata.*;
class HomeBoxConnectionProviderTest {
private final HomeBoxConnectionProvider provider = new HomeBoxConnectionProvider(null, null);
@Test
void checkCredentials_ShouldThrowException_WhenFieldsAreMissing() {
ConnectionRequest request = new ConnectionRequest(MOCK_URL, "HOMEBOX", null, null, false);
EmptyCredentialsException exception = assertThrows(EmptyCredentialsException.class, () -> {
provider.checkCredentials(request);
});
assertThat(exception.getMissingFields()).containsExactly("username", "password");
}
}
@@ -0,0 +1,220 @@
package com.vaessl.app.connection;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import com.vaessl.app.dto.ConnectionRequest;
import static org.assertj.core.api.Assertions.assertThat;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static com.vaessl.app.connection.Endpoint.*;
import static com.vaessl.app.exception.ErrorMessage.*;
import static com.vaessl.app.connection.ServiceType.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
@WireMockTest
class HomeboxIntegrationTest {
@Autowired
TestRestTemplate restTemplate;
@Autowired
ConnectionRepository cRepository;
String okJsonHomeboxResponse = """
{
"token": "fake-jwt-token",
"attachmentToken": "fake-attach",
"expiresAt": "2026-04-26T02:23:13Z"
}
""";
private static final String MOCK_URL = "http://localhost:1234";
private static final String TEST_USER = "admin";
private static final String TEST_PASS = "pw";
/**
* Returns Token and status code OK when login is successful.
*
* @param wm
* the WiremockRuntimeInfo object
*/
@Test
void shouldReturnTokenAndStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getValue())
.willReturn(okJson(okJsonHomeboxResponse)));
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
String.class);
DocumentContext documentContext = JsonPath.parse(response.getBody());
String token = documentContext.read("$.token");
assertThat(token).isEqualTo("fake-jwt-token");
String attachmentToken = documentContext.read("$.extraResponseData.attachmentToken");
assertThat(attachmentToken).isEqualTo("fake-attach");
String expiresAt = documentContext.read("$.expiresAt", String.class);
assertThat(expiresAt).isEqualTo("2026-04-26T02:23:13Z");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
/**
* Tests the Unauthorized custom exception.
*
* @param wm
* the WiremockRuntimeInfo object
*/
@Test
void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized()));
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage());
}
/**
* Tests a server error from the external api.
*
* @param wm
* the WiremockRuntimeInfo object
*/
@Test
void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable()));
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage());
}
/**
* Checks when the service is unavailable or the app URL is wrong.
*/
@Test
void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong() {
ConnectionRequest badRequest = new ConnectionRequest(
MOCK_URL,
HOMEBOX.getValue(),
TEST_USER, TEST_PASS,
false);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
}
/**
* Checks if any login fields are empty since all of them are mandatory.
*/
@Test
void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() {
ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
}
/**
* Test the exception when there is an input for serviceType but it's
* unsupported.
*/
@Test
void shouldReturnWrongServiceTypeException() {
ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(
MOCK_URL,
"wrong-service-type",
TEST_USER, TEST_PASS,
false);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq,
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(response.getBody()).contains(WRONG_SERVICE_TYPE.getMessage());
}
/**
* Tests the succesfull persistance of Homebox credential response to the
* database.
*
* @param wm
* the WiremockRuntimeInfo object
*/
@Test
void shouldSaveHomeboxConnectionResponseToDb(WireMockRuntimeInfo wm) {
stubFor(post(urlEqualTo(HOMEBOX_LOGIN.getValue()))
.willReturn(okJson(okJsonHomeboxResponse)));
ConnectionRequest request = connectionRequest(wm);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), request, String.class);
DocumentContext responseContext = JsonPath.parse(response.getBody());
ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
assertThat(dbEntry).isNotNull();
assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl());
assertThat(dbEntry.getUsername()).isEqualTo(request.username());
if (dbEntry instanceof HomeboxEntity hbE) {
assertThat(hbE.getToken()).isEqualTo(responseContext.read("$.token"));
assertThat(hbE.getAttachmentToken()).isEqualTo(responseContext.read("$.extraResponseData.attachmentToken"));
assertThat(hbE.getExpiresAt()).isEqualTo(responseContext.read("$.expiresAt"));
}
}
@Test
void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing() {
ConnectionRequest missingCredentials = new ConnectionRequest(MOCK_URL, HOMEBOX.getValue(), TEST_USER, null,
false);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), missingCredentials,
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
}
/**
* Creates a valid connection request with a mock Api throuh
* WireMockRuntimeInfo.
*
* @param wm
* the WiremockRuntimeInfo object
* @return a mock api connection request.
*/
private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) {
return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), TEST_USER, TEST_PASS,
null);
}
}
@@ -0,0 +1,9 @@
package com.vaessl.app.connection;
public final class Mockdata {
private Mockdata() {}
public static final String MOCK_URL = "http://localhost:1234";
public static final String MOCK_SERVICE_TYPE = "SERVICE_TYPE";
}
@@ -0,0 +1,9 @@
spring:
datasource:
url : ${DB_TEST_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
driver-class-name: ${PG_DRIVER_CLASS_NAME}
jpa:
hibernate:
ddl-auto: create-drop
@@ -1,11 +1,12 @@
**Vaessl: Technical preparation for adding documentation**
**Vaessl: Initial code-server setup**
Before documenting all my research for the planning phase of the app there are several technical preparations to arrange:
* I will be coding in a [code-server](https://github.com/coder/code-server) docker instance so that I can develop and build my app in a central location. This way I'm not dependent on building the project on every machine I work from.
* I will be coding and documenting in a [code-server](https://github.com/coder/code-server) docker instance so that I can develop and build my app in a central location. This way I'm not dependent on building the project on every machine I work from.
* I will be using a locally hosted docker instance on my Proxmox server to deploy the code-server container.
* After preparing the container I will connect it to my self hosted Git(ea) repository to ensure a git flow from the very beginning.
* To ensure SSL which is recommended for code-server I will use a tunnel with my Pangolin instance and Cloudflare as DNS resolver. This is a temporary solution later it will be changed to a self signed cert with Caddy.
* To ensure SSL which is recommended for code-server I will use a tunnel with my Pangolin instance and Cloudflare as DNS resolver. This is a temporary solution later it will be changed to a self signed cert with Caddy so that is not truly publicly available. If I need to access code-server remotely I will do it via Wireguard tunnel.
* While I'm at it I will set up all the ports for backend and frontend
# Code-Server docker container deployment
@@ -14,7 +15,7 @@ I use Portainer to setup my docker-compose yaml:
```
services:
code-server:
image: code-server-dev
image: code-server-dev:latest #note this is a custom image generated later (docs Preparation folder)
container_name: code-server
environment:
- PUID=1000
@@ -24,55 +25,27 @@ services:
# - HASHED_PASSWORD= #optional
# - SUDO_PASSWORD=password #optional
# - SUDO_PASSWORD_HASH= #optional
# - PROXY_DOMAIN=code-server.my.domain #optional
- PROXY_DOMAIN=code-server.my.domain #this is important to generate a proper forward address for ports 8080 and 5173
- DEFAULT_WORKSPACE=/config/workspace #optional
volumes:
- /home/pi/docker/vscode:/config
ports:
- 8443:8443
- 8124:8080 # spring port
- 5173:5173 # vite port
restart: unless-stopped
```
Before deploying the container I configure a Dockerfile to install openjdk, maven, npm, nodejs and yarn and set the environment variables for Java to ensure the Java and React development. Since the code-server container doesn't come with root access for its default user abc out of the box every root action will be configured here:
```
FROM lscr.io/linuxserver/code-server:latest
USER root
RUN apt update && apt install -y \
openjdk-25-jdk maven \
nodejs npm \
&& npm install -g yarn \
&& rm -rf /var/lib/apt/lists/*
ENV JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
ENV PATH="$JAVA_HOME/bin:${PATH}"
```
Build the custom image and name it code-server-dev which is referenced in the docker-compose image above:
```
docker build -t code-server-dev .
```
Generate ssh keys within the code server folder to connect to my Gitea instance. Navigate to the volume and execute:
```
ssh-keygen -t ed25519
```
Save the keys to the .ssh folder.
Now deploy the docker-compose and check all the configurations:
Check if the keys are there:
```
java --version
mvn --version
npm --version
yarn --version
nodejs --version
ls -l /config/.ssh/id_ed25519
```
@@ -86,8 +59,8 @@ ssh -T -p 222 git@192.168.1.208
Add the key and make the initial git commit in code-server:
```
mkdir where-is-my-stuff
cd where-is-my-stuff
mkdir project-name-folder
cd project-name-folder
touch README.md
git init
git config --global init.defaultBranch main
@@ -95,9 +68,9 @@ git branch -m main
git checkout -b main
git add .
git commit -m "initial commit"git config --global user.email "email@email.com"
git config --global user.name "kasun"
git config --global user.name "user"
git commit -m "initial commit"
git remote add origin ssh://git@192.168.1.208:222/kasun/Where-is-my-stuff.git
git remote add origin ssh://git@192.168.1.208:222/user/project-name-folder.git
git push -u origin main
```
@@ -6,7 +6,7 @@ Vaessl is an AI-powered bridge designed to connect physical reality to digital m
# Technical requirements & stack
## Frontend
- Framework: Next.js (React)
- Framework: Vite (React)
- Styling: Tailwind CSS + SCSS
@@ -52,7 +52,7 @@ The image processing is the heart of Vaessl's "bridge" functionality. It ensures
## Deployment
Vaessl is deployed via Docker Compose for high portability.
- Container 1: Next.js (Web UI)
- Container 1: Vite (Web UI)
- Container 2: Spring Boot
- Container 3: PostgreSQL + pgvector
- Container 4: LiteLLM
+2 -2
View File
@@ -29,12 +29,12 @@ Vaessl is intended to be a useful tool for the public, specifically those utiliz
# Short-Term Roadmap & Milestones
- Phase 1: Foundation (Current)
* Deploy the core infrastructure (PostgreSQL + pgvector, LiteLLM, Spring Boot Skeleton, Next.Js frontend).
* Deploy the core infrastructure (PostgreSQL + pgvector, LiteLLM, Spring Boot Skeleton, Vite frontend).
* Establish the bridge between the Vaessl backend and a demo Homebox instance.
- Phase 2: The Processing Pipeline
* Implement the image processing workflow: Upload $\rightarrow$ AI analysis $\rightarrow$ staging Table.
* Develop the Next.js interface for data refinement and verification.
* Develop the Vite interface for data refinement and verification.
- Phase 3: Semantic Search & Demo
* Integrate Spring AI for embedding generation.
@@ -0,0 +1,43 @@
**Vaessl: code-server adjustments**
Before developing on code-server I configure a Dockerfile to install all packages needed for Spring Boot, Java and Vite.
I install openjdk 25, nodejs 24.x and yarn and set the environment variables for Java.
Since the linuxserver code-server image doesn't come with root access for its default user abc out of the box every privileged action will be baked in here:
```
FROM lscr.io/linuxserver/code-server:latest
USER root
RUN apt update && apt install -y \
curl ca-certificates openjdk-25-jdk \
# Add nodejs for React/Vite
&& curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
&& apt install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Set Java Environment
ENV JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
ENV PATH="$JAVA_HOME/bin:${PATH}"
```
Build the custom image and name it something like code-server-dev:
```
docker build -t code-server-dev .
```
Make sure to add code-server-dev:latest to the image env in the docker-compose file.
Now deploy the docker-compose and check all the configurations:
```
java --version
mvn --version
npm --version
yarn --version
nodejs --version
```
+214
View File
@@ -0,0 +1,214 @@
**Vaessl: Spring Boot setup**
This app will use the current latest version 4.0.4 of Spring Boot and the latest OpenJDK 25 LTS.
The dependencies are chosen to specifically work with Spring AI and PostgreSQL/pgvector. For the AI function OpenAI is chosen since it is known to work with LiteLLM. The PostgreSQL dependency makes sure to include PostGreSQL support for self hosted databases.
# Spring Initializr settings
**Build System: Gradle - Kotlin**
Gradel Kotlin is chosen to make mobile/Android app development easier.
**Spring Boot: 4.0.4**
**Packaging: Jar**
**Configuration: YAML**
**Java: 25**
**Dependencies**
- Lombok
- Spring Boot DevTools
- Spring Web
- Spring Security
- Spring Data JPA
- OpenAI AI
- PostgreSQL Driver
- Validation
# Project Settings
PostGreSQL and OpenAI need an initial setup so that the local instance is able to start. I will comment out Spring Security since user management is an issue for a later iteration of the app.
The build.gradle.kts will look something like this:
```
plugins {
java
id("org.springframework.boot") version "4.0.4"
id("io.spring.dependency-management") version "1.1.7"
}
group = "com.vaessl"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
repositories {
mavenCentral()
}
extra["springAiVersion"] = "2.0.0-M3"
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.ai:spring-ai-starter-model-openai")
compileOnly("org.projectlombok:lombok")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("org.postgresql:postgresql")
annotationProcessor("org.projectlombok:lombok")
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
// testImplementation("org.springframework.boot:spring-boot-starter-security-test")
testImplementation("org.springframework.boot:spring-boot-starter-validation-test")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
dependencyManagement {
imports {
mavenBom("org.springframework.ai:spring-ai-bom:${property("springAiVersion")}")
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
```
To configure OpenAI (which I will use instead of LiteLLM initially since I have an OpenAI Api key and can get going quickly) and PostGreSQL I will create a .env.local file in the root dir, fill all credentials and add it to .gitignore
```
DB_URL=jdbc:postgresql://192.168.1.208:5433/vaessl
DB_TEST_URL=jdbc:postgresql://192.168.1.208:5434/vaessl_test
DB_USERNAME=myusername
DB_PASSWORD=mypw
OPENAI_KEY=myapikey
OPENAI_BASE_URL=https://api.openai.com
PG_DRIVER_CLASS_NAME=org.postgresql.Driver
```
The initial application.yaml in the resources folder will look like this:
```
spring:
application:
name: vaessl
config:
import: "optional:file:.env.local[.properties]"
datasource:
url : ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
driver-class-name: ${PG_DRIVER_CLASS_NAME}
jpa:
hibernate:
ddl-auto: update
show-sql: true
ai:
openai:
base-url: ${OPENAI_BASE_URL}
api-key: ${OPENAI_KEY}
chat:
options:
model: gpt-4o-mini
logging:
level:
org.springframework.boot.context.config: TRACE
```
Note that I'm using my own locally hosted PostgreSQL instances for the main and test database. The Docker Compose file will look something like this:
```
---
services:
code-server:
image: code-server-dev:latest
container_name: code-server
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Vienna
# - PASSWORD= #optional
- HASHED_PASSWORD=hashedpw
# - SUDO_PASSWORD_HASH= #optional
- PROXY_DOMAIN=code-server.your.website
- DEFAULT_WORKSPACE=/config/workspace
volumes:
- /home/pi/docker/vscode:/config
- /home/pi/docker/vscode/workspace:/workspace
ports:
- 8443:8443
- 8124:8080
- 5173:5173
restart: unless-stopped
vaessl-db:
image: pgvector/pgvector:pg18
container_name: vaessl-db
environment:
- POSTGRES_DB=vaessl
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pw
ports:
- 5433:5432
vassal-test-db:
image: pgvector/pgvector:pg18
container_name: vassal-test-db
environment:
- POSTGRES_DB=vassal_test
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pw
ports:
- 5434:5432
```
# Appendix: Additional config for developing in Code-Server
When using the code-server container there are additional config steps to mind:
- Assign the 8080 port to a different port if it is used by another docker container by adding a port variable in the docker-compose.yaml. I assigned it to 8124.
```
ports:
- 8443:8443
- 8124:8080
```
- define a proxy domain for automatic Url generations when starting localhost
```
environment:
- PROXY_DOMAIN=code-server.your.website
```
This makes sure port 8080 is reachable via https://8080.code-server.your.website as per code-server documentation for using subdomains: https://coder.com/docs/code-server/guide#using-a-subdomain
- make sure to add the subdomain in your proxy platform like Cloudflare Zero Trust Tunnel or Pangolin and point it to the local ip in my case http://192.168.208:8124
+26
View File
@@ -0,0 +1,26 @@
**Vaessl: Frontend setup**
Vite will act as development server for the React app. The necessary packages especially the correct NodeJs version are baked into the Docker image documented in [code-server adjustments](https://gitea.kasuns.website/kasun/Vaessl/src/branch/main/docs/02-Preparation/01-code-server-adjustments.md)
Port 5173 needs to be exposed in the code-server container via port variable.
To ensure proper localhost Url generation the following server parameters need to be added to the vite.config.ts.
```
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
allowedHosts: ['5173.code-server.your.website'],
},
})
```
Don't forget to add the host to your proxy service.
+49
View File
@@ -0,0 +1,49 @@
**vaessl: Test strategy**
# Spring Boot Test Environment
1. Environment Parity
Instead of testing against a mocked database or the development database, I created a mirrored test container. This allows the application to perform JPA operations against a real PostgreSQL instance that matches the production engine but uses a different port (5434) and database name (vaessl_test).
2. Profile-Driven Configuration
I utilized the Spring Profiles mechanism. By tagging the test class with @ActiveProfiles("test"), Spring Boot ignores the standard application.yaml for specific keys and prioritizes src/test/resources/application-test.yaml.
The configuration is moved out of the Java code and into YAML. This follows the "Separation of Concerns" principle.
The file is placed in src/test/resources (a sibling to src/test/java). This is the standard Gradle "SourceSet" layout, ensuring the build tool automatically packages these settings only during the test phase.
3. Gradle Test Lifecycle
I corrected the dependency graph in build.gradle.kts. While the Initializr provides "test slices" (like data-jpa-test), a full integration test requires the Spring Boot Starter Test core.
This starter provides the YAML parsing engine and the SpringBootContextLoader.
# Frontend Test environment
1. The Vitest Stack
- Vitest: providing a unified configuration for both the development server and the test runner.
- jsdom: Provides a lightweight browser environment in Node.js.
- React Testing Library: Ensures tests are written from the user's perspective (DOM-based) rather than implementation details.
2. Dynamic Environment Configuration
The vitest.config.ts is configured to be environment-aware. By using loadEnv, the test runner can access variables from .env files, allowing us to:
- Dynamically set the Server Port (51204).
- Handle Allowed Hosts (crucial for code-server or LXC environments where the public URL might change).
- Inject environment-specific keys into the test.env context.
3. TDD Workflow (Red-Green-Refactor)
The environment is optimized for a fast feedback loop.
- npm run test: Runs Vitest in "Watch Mode," re-triggering tests instantly upon file changes.
- npm run test:ui: Launches the Vitest UI, providing a visual graph of test suites and real-time failure reports.
+92
View File
@@ -0,0 +1,92 @@
**vaessl: Setup LiteLLM**
# LiteLLM Docker Compose setup
I chose to use my existing Docker and PostgreSQL instance to spin up a LiteLLM container. I use Portainer stacks to write the docker-compose.yaml. Instead of creating a .env file I just wrote all environment variables into the yaml. The official docker-compose.yaml may vary.
Before deploying the container create a config.yaml and prometheus.yml in the root folder:
*config.yaml*
```
general_settings:
master_key: sk-key # 🔑 your proxy admin key (must start with sk-)
database_url: "postgresql://pgUser:pgUserPw@192.168.1.208:5432/litellm"
```
*prometheus.yml*
```
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: "litellm"
static_configs:
- targets: ["litellm:4000"]
```
*Portainer stack*
```
services:
litellm:
#build:
# context: .
# args:
# target: runtime
image: docker.litellm.ai/berriai/litellm:main-stable
volumes:
- /home/pi/docker/litellm/config.yaml:/app/config.yaml
command:
- "--config=/app/config.yaml"
ports:
- "4000:4000" # Map the container port to the host, change the host port if necessary
environment:
DATABASE_URL: "postgresql://pgUser:pgUserPw@192.168.1.208:5432/litellm"
STORE_MODEL_IN_DB: "True" # allows adding models to proxy via UI
OPENAI_API_KEY: "sk-key"
OPENAI_BASE_URL: "https://api.openai.com/v1"
LITELLM_MASTER_KEY: "sk-key"
LITELLM_SALT_KEY: "sk-saltkey"
PUID: 1000
PGID: 1000
#env_file:
# - /home/pi/docker/litellm/.env
healthcheck:
test:
- CMD-SHELL
- python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')" # Command to execute for health check
interval: 30s # Perform health check every 30 seconds
timeout: 10s # Health check command times out after 10 seconds
retries: 3 # Retry up to 3 times if health check fails
start_period: 40s # Wait 40 seconds after container start before beginning health checks
networks:
- postgres_pg_network
prometheus:
image: prom/prometheus
volumes:
- /home/pi/docker/litellm/prometheus_data:/prometheus
- /home/pi/docker/litellm/prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=15d"
restart: always
volumes:
prometheus_data:
driver: local
postgres_data:
name: litellm_postgres_data # Named volume for Postgres data persistence
networks:
postgres_pg_network:
external: true
```
Models can be added in the UI under localhost:4000 and will be saved into the database. No need to define them in the config.yaml
@@ -0,0 +1,57 @@
**vaessl: Setup API client**
# Locally hosted API client
I chose [Hoppscotch ](https://docs.hoppscotch.io/) as API client since it is free, open-source, has a Docker support and can be locally hosted. Together with its browser extension it can be run in the browser supporting my goal of a flexible, browser-based development environment where I can securely access and interact with my entire stack remotely.
```
services:
hoppscotch:
image: hoppscotch/hoppscotch
container_name: hoppscotch
restart: unless-stopped
# ATTENTION: Run command and entrypoint for the first time and then comment it out
#command: '-c "pnpx prisma migrate deploy"'
#entrypoint: sh
environment:
PUID: 1000
PGID: 1000
# Prisma Config
DATABASE_URL: postgresql://user:pw@192.168.1.208:5432/hoppscotch
# (Optional) By default, the AIO container (when in subpath access mode) exposes the endpoint on port 80. Use this setting to specify a different port if needed.
HOPP_AIO_ALTERNATE_PORT: 80
# Sensitive Data Encryption Key while storing in Database (32 character)
DATA_ENCRYPTION_KEY: 'key'
# Whitelisted origins for the Hoppscotch App.
# This list controls which origins can interact with the app through cross-origin comms.
# - localhost ports (3170, 3000, 3100): app, backend, development servers and services
# - app://localhost_3200: Bundle server origin identifier
# NOTE: `3200` here refers to the bundle server (port 3200) that provides the bundles,
# NOT where the app runs. The app itself uses the `app://` protocol with dynamic
# bundle names like `app://{bundle-name}/`
WHITELISTED_ORIGINS: http://localhost:3170,http://localhost:3009,http://localhost:3101,app://localhost_3200,app://hoppscotch
# Base URLs
VITE_BASE_URL: http://localhost:3009
VITE_SHORTCODE_BASE_URL: http://localhost:3009
VITE_ADMIN_URL: http://localhost:3101
# Backend URLs
VITE_BACKEND_GQL_URL: http://localhost:3170/graphql
VITE_BACKEND_WS_URL: wss://localhost:3170/graphql
VITE_BACKEND_API_URL: http://localhost:3170/v1
# Terms Of Service And Privacy Policy Links (Optional)
VITE_APP_TOS_LINK: https://docs.hoppscotch.io/support/terms
VITE_APP_PRIVACY_POLICY_LINK: https://docs.hoppscotch.io/support/privacy
# Set to `true` for subpath based access
ENABLE_SUBPATH_BASED_ACCESS: false
ports:
- '3170:3170'
- '3101:3100'
- '3009:3000'
networks:
- postgres_pg_network
networks:
postgres_pg_network:
external: true
```
@@ -0,0 +1,302 @@
**vaessl: Login Architecture**
# Backend
The login architecture is designed to make future additions to this bridging app as frictionless as possible. Abstraction and inheritance will be used as good as possible to keep refactorings to a minimum. The first app to bridge will be Homebox which uses a classic username, password and Bearer token login proces to authenticate calls to its api. The second hypothetic app will be WikiJs which simply uses a user generated api key. The abstraction of the Java classes will try to cater to both authentication methods.
## Single Table Inheritance
The database entities will follow the Single Table Inheritance concept.
The database will have one "connections" table that has all the columnns of every supported app. So in this case the table will have username, password and attachment token which are specific to Homebox. Both Homebox and WikiJs will share the token field.
The entities will be organized with an abstract ConnectionEntitiy class containing the id and appUrl and the app specific entities like HomeboxEntitiy:
***ConnectionEntitiy.java***
```
package com.vaessl.app.connection;
import jakarta.persistence.DiscriminatorColumn;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Inheritance;
import jakarta.persistence.InheritanceType;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "connections")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "service_type")
@Getter
@Setter
@NoArgsConstructor
public abstract class ConnectionEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
private String appUrl;
}
```
***HomeboxEntitiy.java***
```
package com.vaessl.app.connection;
import java.time.Instant;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity;
import lombok.Getter;
import lombok.Setter;
@Entity
@DiscriminatorValue("HOMEBOX")
@Getter
@Setter
public class HomeboxEntity extends ConnectionEntity {
private String token;
private String attachmentToken;
private Instant expiresAt;
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
HomeboxEntity he = new HomeboxEntity();
he.setAppUrl(request.appUrl());
he.setToken(response.token());
he.setAttachmentToken(response.getExtraVar("attachmentToken"));
he.setExpiresAt(response.expiresAt());
return he;
}
}
```
## The Provider Pattern (Logic Layer)
To keep the core business logic clean, the app uses a Provider Pattern. This separates how the app authenticates (the Specific) from what the system does with that info (the General).
- ConnectionProvider Interface: Defines the contract. Every new app (Homebox, WikiJS, etc.) must implement this interface to tell the system how to authenticate and how to map its specific API response into a database entity.
***ConnectionProvider.java***
```
package com.vaessl.app.connection;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
public interface ConnectionProvider {
String getServiceType();
ConnectionResponse authenticate(ConnectionRequest request);
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
}
```
***HomeboxConnectionProvider.java***
```
package com.vaessl.app.connection;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import static com.vaessl.app.connection.Endpoint.*;
@Component
public class HomeBoxConnectionProvider implements ConnectionProvider {
private final RestClient.Builder restClientBuilder;
private final ConnectionRepository cRepository;
public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) {
this.restClientBuilder = restClientBuilder;
this.cRepository = cRepository;
}
@Override
public String getServiceType() {
return "HOMEBOX";
}
@Override
public ConnectionResponse authenticate(ConnectionRequest request) {
Map<String, Object> homeboxPayload = Map.of("username", request.credentials().get("username"),
"password", request.credentials().get("password"), "stayLoggedIn",
request.stayLoggedIn());
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl())
.build()
.post()
.uri(HOMEBOX_LOGIN.getValue())
.body(homeboxPayload)
.retrieve()
.body(HomeboxLoginResponse.class);
if (hbResponse == null) {
throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl());
}
Map<String, Object> attachmentToken = new HashMap<>();
attachmentToken.put("attachmentToken", hbResponse.attachmentToken());
return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken);
}
@Override
public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) {
return HomeboxEntity.from(request, response);
}
@Override
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
if (existing instanceof HomeboxEntity hbE) {
hbE.setToken(response.token());
hbE.setExpiresAt(response.expiresAt());
hbE.setAttachmentToken(response.getExtraVar("attachmentToken"));
cRepository.save(hbE);
}
}
private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) {
}
}
```
- Provider Registry: The ConnectionService automatically detects all implementations of the provider interface and stores them in a map. When a login request comes in, the service simply looks up the correct provider by its "Service Type" string.
***ConnectionService.java***
```
package com.vaessl.app.connection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import com.vaessl.app.exception.ProviderNotFoundException;
@Service
public class ConnectionService {
private final Map<String, ConnectionProvider> providerRegistry;
private final ConnectionRepository cRepository;
public ConnectionService(List<ConnectionProvider> providers, ConnectionRepository cRepository) {
this.providerRegistry = providers.stream()
.collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p));
this.cRepository = cRepository;
}
public ConnectionResponse login(ConnectionRequest request) {
ConnectionProvider provider = providerRegistry.get(request.serviceType());
if (provider == null) {
throw new ProviderNotFoundException();
}
ConnectionResponse response = provider.authenticate(request);
ConnectionEntity existing = cRepository.findByAppUrl(request.appUrl());
if (existing != null) {
provider.updateToRepository(existing, response);
} else {
ConnectionEntity newEntity = provider.connectionToEntity(request, response);
cRepository.save(newEntity);
}
return response;
}
}
```
## Generic Data Exchange (The DTOs)
Since different apps return different types of data (e.g., Homebox returns an attachmentToken, but WikiJS might return a something else), I use a Generic Data Bridge to move information between the API and the Database.
- ConnectionRequest: A universal "envelope" containing common fields (appUrl, serviceType) and a flexible Map<String, String> for credentials. This allows one DTO to handle both username/password logins and API-key-only logins.
***ConnectionRequest.java***
```
package com.vaessl.app.dto;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
public record ConnectionRequest(
@NotBlank(message = "App URL is mandatory") String appUrl,
@NotBlank(message = "Service type is mandatory") String serviceType,
@NotEmpty(message = "Credentials are mandatory") Map<String, String> credentials,
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
public ConnectionRequest {
if (stayLoggedIn == null) {
stayLoggedIn = false;
}
}
}
```
- ConnectionResponse: A "Smart" DTO that holds the core authentication data (the token and expiresAt) and a Map<String, Object> called extraResponseData.
- The "Smart" Getter: The response object contains helper methods to safely extract app-specific variables from this map. This allows the system to be "Generic" while still giving specific entities (like HomeboxEntity) access to the unique data they need.
***ConnectionResponse.java***
```
package com.vaessl.app.dto;
import java.time.Instant;
import java.util.Map;
public record ConnectionResponse(String token, Instant expiresAt, Map<String, Object> extraResponseData) {
public String getExtraVar(String key) {
if(extraResponseData == null) {
return null;
} else {
Object value = extraResponseData.get(key);
return value != null ? String.valueOf(value) : null;
}
}
}
```
+25
View File
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vite
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4176
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/ui": "^4.1.2",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^29.0.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1",
"vitest": "^4.1.2"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+121
View File
@@ -0,0 +1,121 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
}
export default App
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+111
View File
@@ -0,0 +1,111 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+7
View File
@@ -0,0 +1,7 @@
import { it, expect, describe } from 'vitest'
describe('group', () => {
it('should', () => {
expect(1).toBeTruthy();
})
})
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
allowedHosts: ['5173.code-server.kasuns.website'],
},
})
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config'
import { loadEnv } from 'vite'
export default defineConfig(({mode}) => {
const env = loadEnv(mode, process.cwd(), '')
return {
server: {
port: 51204,
host: '0.0.0.0',
allowedHosts: env.VITEST_PUBLIC_URL ? [env.VITEST_PUBLIC_URL] : [],
},
test: {
environment: 'jsdom',
env: loadEnv(mode, process.cwd(), '')
}
}
});