Web UI setup

This commit is contained in:
nemunaire 2025-10-17 17:18:37 +07:00
commit 4cd184779e
19 changed files with 6026 additions and 2 deletions

View file

@ -1,5 +1,17 @@
# Multi-stage Dockerfile for happyDeliver with integrated MTA
# Stage 1: Build the Go application
# Stage 1: Build the Svelte application
FROM node:22-alpine AS nodebuild
WORKDIR /build
COPY api/ api/
COPY web/ web/
RUN yarn --cwd web install && \
yarn --cwd web run generate:api && \
yarn --cwd web --offline build
# Stage 2: Build the Go application
FROM golang:1-alpine AS builder
WORKDIR /build
@ -13,12 +25,13 @@ RUN go mod download
# Copy source code
COPY . .
COPY --from=nodebuild /build/web/build/ ./web/build/
# Build the application
RUN go generate ./... && \
CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver
# Stage 2: Runtime image with Postfix and all filters
# Stage 3: Runtime image with Postfix and all filters
FROM alpine:3
# Install all required packages

View file

@ -34,6 +34,7 @@ import (
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/receiver"
"git.happydns.org/happyDeliver/internal/storage"
"git.happydns.org/happyDeliver/web"
)
const version = "0.1.0-dev"
@ -89,6 +90,7 @@ func runServer(cfg *config.Config) {
// Register API routes
apiGroup := router.Group("/api")
api.RegisterHandlers(apiGroup, handler)
web.DeclareRoutes(cfg, router)
// Start server
log.Printf("Starting API server on %s", cfg.Bind)

26
web/.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# OpenAPI
src/lib/api

1
web/.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

9
web/.prettierignore Normal file
View file

@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

13
web/.prettierrc Normal file
View file

@ -0,0 +1,13 @@
{
"tabWidth": 4,
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

43
web/assets.go Normal file
View file

@ -0,0 +1,43 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package web
import (
"embed"
"io/fs"
"log"
"net/http"
)
//go:embed all:build
var _assets embed.FS
var Assets http.FileSystem
func init() {
sub, err := fs.Sub(_assets, "build")
if err != nil {
log.Fatal("Unable to cd to build/ directory:", err)
}
Assets = http.FS(sub)
}

41
web/eslint.config.js Normal file
View file

@ -0,0 +1,41 @@
import prettier from "eslint-config-prettier";
import { fileURLToPath } from "node:url";
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import svelte from "eslint-plugin-svelte";
import { defineConfig } from "eslint/config";
import globals from "globals";
import ts from "typescript-eslint";
import svelteConfig from "./svelte.config.js";
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
"no-undef": "off",
},
},
{
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: [".svelte"],
parser: ts.parser,
svelteConfig,
},
},
},
);

12
web/openapi-ts.config.ts Normal file
View file

@ -0,0 +1,12 @@
import { defineConfig } from "@hey-api/openapi-ts";
export default defineConfig({
input: "../api/openapi.yaml",
output: "src/lib/api",
plugins: [
{
name: "@hey-api/client-fetch",
runtimeConfigPath: "./src/lib/hey-api.ts",
},
],
});

5532
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

43
web/package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "happyDeliver",
"version": "0.1.0",
"type": "module",
"license": "AGPL-3.0-or-later",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"test": "vitest",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"generate:api": "openapi-ts"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.36.0",
"@hey-api/openapi-ts": "0.80.0",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@types/node": "^22",
"eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4",
"globals": "^16.4.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.10",
"vitest": "^3.2.4"
},
"dependencies": {
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1"
}
}

171
web/routes.go Normal file
View file

@ -0,0 +1,171 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package web
import (
"encoding/json"
"io"
"io/fs"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"path"
"strings"
"text/template"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDeliver/internal/config"
)
var (
indexTpl *template.Template
CustomHeadHTML = ""
)
func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
appConfig := map[string]interface{}{}
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
log.Println("Unable to generate JSON config to inject in web application")
} else {
CustomHeadHTML += `<script id="app-config" type="application/json">` + string(appcfg) + `</script>`
}
if cfg.DevProxy != "" {
router.GET("/.svelte-kit/*_", serveOrReverse("", cfg))
router.GET("/node_modules/*_", serveOrReverse("", cfg))
router.GET("/@vite/*_", serveOrReverse("", cfg))
router.GET("/@id/*_", serveOrReverse("", cfg))
router.GET("/@fs/*_", serveOrReverse("", cfg))
router.GET("/src/*_", serveOrReverse("", cfg))
router.GET("/home/*_", serveOrReverse("", cfg))
}
router.GET("/_app/", serveOrReverse("", cfg))
router.GET("/_app/immutable/*_", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
router.GET("/", serveOrReverse("/", cfg))
router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
router.GET("/img/*path", serveOrReverse("", cfg))
router.NoRoute(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api") || strings.Contains(c.Request.Header.Get("Accept"), "application/json") {
c.JSON(404, gin.H{"code": "PAGE_NOT_FOUND", "errmsg": "Page not found"})
} else {
serveOrReverse("/", cfg)(c)
}
})
}
func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
if cfg.DevProxy != "" {
// Forward to the Svelte dev proxy
return func(c *gin.Context) {
if u, err := url.Parse(cfg.DevProxy); err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
} else {
if forced_url != "" {
u.Path = path.Join(u.Path, forced_url)
} else {
u.Path = path.Join(u.Path, c.Request.URL.Path)
}
u.RawQuery = c.Request.URL.RawQuery
if r, err := http.NewRequest(c.Request.Method, u.String(), c.Request.Body); err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
} else if resp, err := http.DefaultClient.Do(r); err != nil {
http.Error(c.Writer, err.Error(), http.StatusBadGateway)
} else {
defer resp.Body.Close()
if u.Path != "/" || resp.StatusCode != 200 {
for key := range resp.Header {
c.Writer.Header().Add(key, resp.Header.Get(key))
}
c.Writer.WriteHeader(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
} else {
for key := range resp.Header {
if strings.ToLower(key) != "content-length" {
c.Writer.Header().Add(key, resp.Header.Get(key))
}
}
v, _ := ioutil.ReadAll(resp.Body)
v2 := strings.Replace(string(v), "</head>", "{{ .Head }}</head>", 1)
indexTpl = template.Must(template.New("index.html").Parse(v2))
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
"Head": CustomHeadHTML,
}); err != nil {
log.Println("Unable to return index.html:", err.Error())
}
}
}
}
}
} else if Assets == nil {
return func(c *gin.Context) {
c.String(http.StatusNotFound, "404 Page not found - interface not embedded in binary, please compile with -tags web")
}
} else if forced_url == "/" {
// Serve altered index.html
return func(c *gin.Context) {
if indexTpl == nil {
// Create template from file
f, _ := Assets.Open("index.html")
v, _ := ioutil.ReadAll(f)
v2 := strings.Replace(string(v), "</head>", "{{ .Head }}</head>", 1)
indexTpl = template.Must(template.New("index.html").Parse(v2))
}
// Serve template
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
"Head": CustomHeadHTML,
}); err != nil {
log.Println("Unable to return index.html:", err.Error())
}
}
} else if forced_url != "" {
// Serve forced_url
return func(c *gin.Context) {
c.FileFromFS(forced_url, Assets)
}
} else {
// Serve requested file
return func(c *gin.Context) {
if _, err := fs.Stat(_assets, path.Join("build", c.Request.URL.Path)); os.IsNotExist(err) {
c.FileFromFS("/404.html", Assets)
} else {
c.FileFromFS(c.Request.URL.Path, Assets)
}
}
}
}

13
web/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
web/src/app.html Normal file
View file

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

30
web/src/lib/hey-api.ts Normal file
View file

@ -0,0 +1,30 @@
import type { CreateClientConfig } from "./api/client.gen";
export class NotAuthorizedError extends Error {
constructor(message: string) {
super(message);
this.name = "NotAuthorizedError";
}
}
async function customFetch(url: string, init: RequestInit): Promise<Response> {
const response = await fetch(url, init);
if (response.status === 400) {
const json = await response.json();
if (
json.error ==
"error in openapi3filter.SecurityRequirementsError: security requirements failed: invalid session"
) {
throw new NotAuthorizedError(json.error.substring(80));
}
}
return response;
}
export const createClientConfig: CreateClientConfig = (config) => ({
...config,
baseUrl: "/api/",
fetch: customFetch,
});

1
web/src/lib/index.ts Normal file
View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

19
web/svelte.config.js Normal file
View file

@ -0,0 +1,19 @@
import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
fallback: "index.html",
}),
paths: {
relative: process.env.MODE === "production",
},
},
};
export default config;

19
web/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

25
web/vite.config.ts Normal file
View file

@ -0,0 +1,25 @@
import { defineConfig } from "vitest/config";
import { sveltekit } from "@sveltejs/kit/vite";
export default defineConfig({
server: {
hmr: {
port: 10000,
},
},
plugins: [sveltekit()],
test: {
expect: { requireAssertions: true },
projects: [
{
extends: "./vite.config.ts",
test: {
name: "server",
environment: "node",
include: ["src/**/*.{test,spec}.{js,ts}"],
exclude: ["src/**/*.svelte.{test,spec}.{js,ts}"],
},
},
],
},
});