commit a7933f8b0627360b76b2eb7bf8afcd088086b01a Author: Kenneth Date: Tue Nov 12 00:31:10 2024 +0000 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58db722 --- /dev/null +++ b/.gitignore @@ -0,0 +1,216 @@ +# Created by https://www.toptal.com/developers/gitignore/api/macos,windows,linux,go,goland +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows,linux,go,goland + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### GoLand ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### GoLand Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/macos,windows,linux,go,goland + +data.sqlite diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..41a7019 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:data.sqlite + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..fc29074 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,15 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..30bab2a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..ff0056d --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..c0e01ca --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/tesseract.iml b/.idea/tesseract.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/tesseract.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/cmd/tesseract/main.go b/cmd/tesseract/main.go new file mode 100644 index 0000000..6659c1a --- /dev/null +++ b/cmd/tesseract/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "github.com/golang-migrate/migrate/v4" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "log" + "net/http" + "os" + "path/filepath" + "tesseract/internal/migration" + "tesseract/internal/reverseproxy" + "tesseract/internal/service" + "tesseract/internal/template" + "tesseract/internal/workspace" +) + +func main() { + execPath, err := os.Executable() + if err != nil { + log.Fatalln(err) + } + + var configPath string + flag.StringVar(&configPath, "config", filepath.Join(execPath, "config.json"), "absolute/relative path to the config file.") + + flag.Parse() + + configPath, err = filepath.Abs(configPath) + if err != nil { + log.Fatalln(err) + } + + f, err := os.Open(configPath) + if err != nil { + log.Fatalln(err) + } + + config, err := service.ReadConfigFrom(f) + if err != nil { + log.Fatalln(err) + } + + services, err := service.Initialize(config) + if err != nil { + log.Fatalln(err) + } + + err = migration.Up(fmt.Sprintf("sqlite3://%s", config.DatabasePath)) + if err != nil && !errors.Is(err, migrate.ErrNoChange) { + log.Fatalln(err) + } + + proxy := reverseproxy.New(services) + err = proxy.Start() + if err != nil { + log.Fatalln(err) + } + + apiServer := echo.New() + apiServer.Use(services.Middleware()) + apiServer.Use(proxy.Middleware()) + g := apiServer.Group("/api") + workspace.DefineRoutes(g) + template.DefineRoutes(g) + + root := echo.New() + root.Use(middleware.CORS()) + + root.Any("/*", func(c echo.Context) error { + req := c.Request() + res := c.Response() + + if proxy.ShouldHandleRequest(c) { + proxy.ServeHTTP(res, req) + } else { + apiServer.ServeHTTP(res, req) + } + + return nil + }) + + apiServer.HTTPErrorHandler = func(err error, c echo.Context) { + var he *echo.HTTPError + if errors.As(err, &he) { + if err = c.JSON(he.Code, he.Message); err != nil { + c.Logger().Error(err) + _ = c.NoContent(http.StatusInternalServerError) + } + } else { + c.Logger().Error(err) + } + } + + root.Logger.Fatal(root.Start(":8080")) +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..3a3baba --- /dev/null +++ b/config.json @@ -0,0 +1,5 @@ +{ + "databasePath": "./data.sqlite", + "templateDirectoryPath": "./templates", + "hostName": "lycoris.lab" +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6df8d71 --- /dev/null +++ b/go.mod @@ -0,0 +1,72 @@ +module tesseract + +go 1.22.0 + +require ( + github.com/docker/docker v27.3.1+incompatible + github.com/golang-migrate/migrate/v4 v4.18.1 + github.com/google/uuid v1.6.0 + github.com/labstack/echo/v4 v4.12.0 + github.com/olahol/melody v1.2.1 + github.com/uptrace/bun v1.2.5 + github.com/uptrace/bun/dialect/sqlitedialect v1.2.5 + github.com/uptrace/bun/driver/sqliteshim v1.2.5 + github.com/uptrace/bun/extra/bundebug v1.2.5 + modernc.org/sqlite v1.33.1 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.24 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect + go.opentelemetry.io/otel v1.31.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 // indirect + go.opentelemetry.io/otel/metric v1.31.0 // indirect + go.opentelemetry.io/otel/sdk v1.31.0 // indirect + go.opentelemetry.io/otel/trace v1.31.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/crypto v0.28.0 // indirect + golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + golang.org/x/time v0.5.0 // indirect + gotest.tools/v3 v3.5.1 // indirect + modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // indirect + modernc.org/libc v1.61.0 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1c8a8bd --- /dev/null +++ b/go.sum @@ -0,0 +1,217 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= +github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= +github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/olahol/melody v1.2.1 h1:xdwRkzHxf+B0w4TKbGpUSSkV516ZucQZJIWLztOWICQ= +github.com/olahol/melody v1.2.1/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4= +github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/uptrace/bun v1.2.5 h1:gSprL5xiBCp+tzcZHgENzJpXnmQwRM/A6s4HnBF85mc= +github.com/uptrace/bun v1.2.5/go.mod h1:vkQMS4NNs4VNZv92y53uBSHXRqYyJp4bGhMHgaNCQpY= +github.com/uptrace/bun/dialect/sqlitedialect v1.2.5 h1:liDvMaIWrN8DrHcxVbviOde/VDss9uhcqpcTSL3eJjc= +github.com/uptrace/bun/dialect/sqlitedialect v1.2.5/go.mod h1:Mw6IDL/jNUL5ozcREAezOJSZ9Jm4LJlfoaXxBEfNBlM= +github.com/uptrace/bun/driver/sqliteshim v1.2.5 h1:pnGpzrsFy4MEJMAQwUPXzynncVpjFviE27Zz3RyBJUo= +github.com/uptrace/bun/driver/sqliteshim v1.2.5/go.mod h1:3C4tvcYu1As9zUa9Wlik338o1IB5GECwC+b7FJyjNco= +github.com/uptrace/bun/extra/bundebug v1.2.5 h1:DsI/gl4jvq5tQ84yPqnlRYIQ4U6AouTqxJ8Y5Oijfz4= +github.com/uptrace/bun/extra/bundebug v1.2.5/go.mod h1:JFeYvklf5p92ZILXx4siMe2tEVn5JLelww3IGcJb4yA= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= +golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= +google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 h1:T6rh4haD3GVYsgEfWExoCZA2o2FmbNyKpTuAxbEFPTg= +google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:wp2WsuBYj6j8wUdo3ToZsdxxixbvQNAHqVJrTgi5E5M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1:QCqS/PdaHTSWGvupk2F/ehwHtGc0/GYkT+3GAcR1CCc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4= +modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M= +modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY= +modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE= +modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= +modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/migration/migration.go b/internal/migration/migration.go new file mode 100644 index 0000000..a1b0f83 --- /dev/null +++ b/internal/migration/migration.go @@ -0,0 +1,27 @@ +package migration + +import ( + "embed" + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/sqlite3" + _ "github.com/golang-migrate/migrate/v4/source/file" + "github.com/golang-migrate/migrate/v4/source/iofs" + _ "modernc.org/sqlite" +) + +//go:embed sql/*.sql +var migrationFS embed.FS + +func Up(url string) error { + d, err := iofs.New(migrationFS, "sql") + if err != nil { + return err + } + + m, err := migrate.NewWithSourceInstance("iofs", d, url) + if err != nil { + return err + } + + return m.Up() +} diff --git a/internal/migration/sql/1_initial.up.sql b/internal/migration/sql/1_initial.up.sql new file mode 100644 index 0000000..08e4a41 --- /dev/null +++ b/internal/migration/sql/1_initial.up.sql @@ -0,0 +1,50 @@ +CREATE TABLE IF NOT EXISTS workspaces +( + id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL UNIQUE, + container_id TEXT NOT NULL, + image_tag TEXT NOT NULL, + created_at TEXT NOT NULL, + + CONSTRAINT pk_workspaces PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS templates +( + id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL, + created_on TEXT NOT NULL, + last_modified_on TEXT NOT NULL, + is_built INTEGER NOT NULL, + + CONSTRAINT pk_templates PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS template_files +( + template_id TEXT NOT NULL, + file_path TEXT NOT NULL, + content BLOB NOT NULL, + + CONSTRAINT pk_template_files PRIMARY KEY (template_id, file_path) +); + +CREATE TABLE IF NOT EXISTS template_images +( + template_id TEXT NOT NULL, + image_tag TEXT NOT NULL, + image_id TEXT NOT NULL, + + CONSTRAINT pk_template_images PRIMARY KEY (template_id, image_tag, image_id) +); + +CREATE TABLE IF NOT EXISTS port_mappings +( + workspace_id TEXT NOT NULL, + container_port INTEGER NOT NULL, + host_port INTEGER NOT NULL, + subdomain TEXT, + + CONSTRAINT pk_port_mappings PRIMARY KEY (workspace_id, container_port, host_port) +) \ No newline at end of file diff --git a/internal/reverseproxy/proxy.go b/internal/reverseproxy/proxy.go new file mode 100644 index 0000000..a55f052 --- /dev/null +++ b/internal/reverseproxy/proxy.go @@ -0,0 +1,133 @@ +package reverseproxy + +import ( + "fmt" + "github.com/labstack/echo/v4" + "net/http" + "net/http/httputil" + "net/url" + "regexp" + "strings" + "tesseract/internal/service" +) + +type ReverseProxy struct { + *echo.Echo + + services service.Services + httpProxies map[string]*httputil.ReverseProxy +} + +type portMapping struct { + subdomain string + containerPort int + hostPort int +} + +const keyReverseProxy = "reverseProxy" + +func New(services service.Services) *ReverseProxy { + e := echo.New() + proxy := &ReverseProxy{ + e, + services, + make(map[string]*httputil.ReverseProxy), + } + + e.Any("/*", proxy.handleRequest) + + return proxy +} + +func From(c echo.Context) *ReverseProxy { + return c.Get(keyReverseProxy).(*ReverseProxy) +} + +func (p *ReverseProxy) Start() error { + rows, err := p.services.Database.Query("SELECT container_port, host_port, subdomain FROM port_mappings;") + if err != nil { + return err + } + defer rows.Close() + + var mappings []portMapping + for rows.Next() { + mapping := portMapping{} + err = rows.Scan(&mapping.containerPort, &mapping.hostPort, &mapping.subdomain) + if err != nil { + return err + } + } + + for _, m := range mappings { + if m.subdomain == "" { + continue + } + + u, err := url.Parse(fmt.Sprintf("http://localhost:%d", m.hostPort)) + if err != nil { + continue + } + + proxy := httputil.NewSingleHostReverseProxy(u) + p.httpProxies[m.subdomain] = proxy + } + + return nil +} + +func (p *ReverseProxy) Middleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + c.Set(keyReverseProxy, p) + return next(c) + } + } +} + +func (p *ReverseProxy) ShouldHandleRequest(c echo.Context) bool { + config := p.services.Config + h := strings.Replace(config.HostName, ".", "\\.", -1) + reg, err := regexp.Compile(".*\\." + h) + if err != nil { + return false + } + return reg.MatchString(c.Request().Host) +} + +func (p *ReverseProxy) handleRequest(c echo.Context) error { + req := c.Request() + res := c.Response() + config := p.services.Config + + h := strings.Replace(config.HostName, ".", "\\.", -1) + reg, err := regexp.Compile(fmt.Sprintf("(?P.*)\\.%v", h)) + if err != nil { + return err + } + + matches := reg.FindStringSubmatch(req.Host) + if len(matches) == 0 { + return echo.NewHTTPError(http.StatusNotFound) + } + + var subdomain string + for i, name := range reg.SubexpNames() { + if i != 0 && name == "subdomain" { + subdomain = matches[i] + break + } + } + if subdomain == "" { + return echo.NewHTTPError(http.StatusNotFound) + } + + proxy, ok := p.httpProxies[subdomain] + if !ok { + return echo.NewHTTPError(http.StatusNotFound) + } + + proxy.ServeHTTP(res, req) + + return nil +} diff --git a/internal/service/config.go b/internal/service/config.go new file mode 100644 index 0000000..0d0215d --- /dev/null +++ b/internal/service/config.go @@ -0,0 +1,33 @@ +package service + +import ( + "encoding/json" + "io" + "path/filepath" +) + +type Config struct { + DatabasePath string `json:"databasePath"` + TemplateDirectoryPath string `json:"templateDirectoryPath"` + HostName string `json:"hostName"` +} + +func ReadConfigFrom(reader io.Reader) (Config, error) { + var config Config + err := json.NewDecoder(reader).Decode(&config) + if err != nil { + return Config{}, err + } + + config.DatabasePath, err = filepath.Abs(config.DatabasePath) + if err != nil { + return Config{}, err + } + + config.TemplateDirectoryPath, err = filepath.Abs(config.TemplateDirectoryPath) + if err != nil { + return Config{}, err + } + + return config, nil +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..a502515 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,79 @@ +package service + +import ( + "database/sql" + "github.com/docker/docker/client" + _ "github.com/golang-migrate/migrate/v4/source/file" + "github.com/labstack/echo/v4" + "github.com/olahol/melody" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect/sqlitedialect" + "github.com/uptrace/bun/driver/sqliteshim" + "github.com/uptrace/bun/extra/bundebug" + _ "modernc.org/sqlite" + "net/http" +) + +const ( + keyHTTPClient = "httpClient" + keyDockerClient = "dockerClient" + keyDB = "db" + keyConfig = "config" + keyMelody = "melody" +) + +type Services struct { + HTTPClient *http.Client + DockerClient *client.Client + Database *bun.DB + Config Config + Melody *melody.Melody +} + +func HTTPClient(c echo.Context) *http.Client { + return c.Get(keyHTTPClient).(*http.Client) +} + +func DockerClient(c echo.Context) *client.Client { + return c.Get(keyDockerClient).(*client.Client) +} + +func Database(c echo.Context) *bun.DB { + return c.Get(keyDB).(*bun.DB) +} + +func Initialize(config Config) (Services, error) { + hc := &http.Client{} + + docker, err := client.NewClientWithOpts(client.FromEnv) + if err != nil { + return Services{}, err + } + + db, err := sql.Open(sqliteshim.ShimName, config.DatabasePath) + if err != nil { + return Services{}, err + } + bundb := bun.NewDB(db, sqlitedialect.New()) + bundb.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true))) + + return Services{ + HTTPClient: hc, + DockerClient: docker, + Database: bundb, + Config: config, + Melody: melody.New(), + }, nil +} + +func (s Services) Middleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + c.Set(keyHTTPClient, s.HTTPClient) + c.Set(keyDockerClient, s.DockerClient) + c.Set(keyDB, s.Database) + c.Set(keyConfig, s.Config) + return next(c) + } + } +} diff --git a/internal/template/docker_template.go b/internal/template/docker_template.go new file mode 100644 index 0000000..df75bd7 --- /dev/null +++ b/internal/template/docker_template.go @@ -0,0 +1,111 @@ +package template + +import ( + "archive/tar" + "bytes" + "context" + "errors" + "github.com/docker/docker/api/types" + "github.com/docker/docker/client" + "github.com/google/uuid" + "github.com/uptrace/bun" + "io" + "time" +) + +type createTemplateOptions struct { + name string + description string +} + +type templateBuildOptions struct { + imageTag string + buildArgs map[string]*string +} + +func createDockerTemplate(ctx context.Context, tx bun.Tx, opts createTemplateOptions) (*template, error) { + id, err := uuid.NewV7() + if err != nil { + return nil, err + } + + now := time.Now().Format(time.RFC3339) + + t := template{ + ID: id, + Name: opts.name, + Description: opts.description, + CreatedOn: now, + LastModifiedOn: now, + IsBuilt: false, + } + dockerfile := templateFile{ + TemplateID: id, + FilePath: "Dockerfile", + Content: make([]byte, 0), + } + readme := templateFile{ + TemplateID: id, + FilePath: "README.md", + Content: make([]byte, 0), + } + files := []templateFile{dockerfile, readme} + + if err = tx.NewInsert().Model(&t).Returning("*").Scan(ctx); err != nil { + return nil, err + } + + if err = tx.NewInsert().Model(&files).Scan(ctx); err != nil { + return nil, err + } + + return &t, nil +} + +func buildDockerTemplate(ctx context.Context, docker *client.Client, tmpl *template, opts templateBuildOptions) (io.ReadCloser, error) { + if len(tmpl.Files) == 0 { + return nil, errors.New("cannot build docker template: no files in template") + } + + buf := new(bytes.Buffer) + tw := tar.NewWriter(buf) + defer tw.Close() + + var dockerfile []byte + for _, file := range tmpl.Files { + if file.FilePath == "Dockerfile" { + dockerfile = file.Content + break + } + } + if len(dockerfile) == 0 { + return nil, errors.New("cannot build docker template: template does not contain Dockerfile") + } + + h := tar.Header{ + Name: "Dockerfile", + Size: int64(len(dockerfile)), + } + err := tw.WriteHeader(&h) + if err != nil { + return nil, err + } + + _, err = tw.Write(dockerfile) + if err != nil { + return nil, err + } + + r := bytes.NewReader(buf.Bytes()) + + res, err := docker.ImageBuild(ctx, r, types.ImageBuildOptions{ + Context: r, + Tags: []string{opts.imageTag}, + BuildArgs: opts.buildArgs, + }) + if err != nil { + return nil, err + } + + return res.Body, nil +} diff --git a/internal/template/http_handlers.go b/internal/template/http_handlers.go new file mode 100644 index 0000000..686abe4 --- /dev/null +++ b/internal/template/http_handlers.go @@ -0,0 +1,444 @@ +package template + +import ( + "bufio" + "database/sql" + "encoding/json" + "errors" + "fmt" + "github.com/labstack/echo/v4" + "io" + "net/http" + "strings" + "tesseract/internal/service" +) + +type createTemplateRequestBody struct { + Description string `json:"description"` + Content string `json:"content"` + Documentation string `json:"documentation"` +} + +type updateTemplateRequestBody struct { + Description *string `json:"description"` + Files []templateFile `json:"files"` + + ImageTag *string `json:"imageTag"` + BuildArgs map[string]*string `json:"buildArgs"` +} + +type templateBuildLogEvent struct { + Type string `json:"type"` + LogContent string `json:"logContent"` +} + +type templateBuildFinishedEvent struct { + Type string `json:"type"` + Template *template `json:"template"` +} + +func fetchAllTemplates(c echo.Context) error { + db := service.Database(c) + + var templates []template + err := db.NewSelect().Model(&templates).Scan(c.Request().Context()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return c.JSON(http.StatusOK, make([]template, 0)) + } + return err + } + + if len(templates) == 0 { + return c.JSON(http.StatusOK, make([]template, 0)) + } + return c.JSON(http.StatusOK, templates) +} + +func fetchTemplate(c echo.Context) error { + db := service.Database(c) + + name := c.Param("templateName") + if strings.TrimSpace(name) == "" { + return echo.NewHTTPError(http.StatusNotFound) + } + + var tmpl template + err := db.NewSelect().Model(&tmpl). + Relation("Files"). + Where("name = ?", name). + Scan(c.Request().Context()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } + return err + } + + if len(tmpl.Files) > 0 { + tmpl.FileMap = make(map[string]*templateFile) + } + for _, f := range tmpl.Files { + tmpl.FileMap[f.FilePath] = f + } + + return c.JSON(http.StatusOK, tmpl) +} + +func createOrUpdateTemplate(c echo.Context) error { + db := service.Database(c) + + name := c.Param("templateName") + if strings.TrimSpace(name) == "" { + return echo.NewHTTPError(http.StatusNotFound) + } + + exists, err := db.NewSelect(). + Table("templates"). + Where("name = ?", name). + Exists(c.Request().Context()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return createTemplate(c) + } + return err + } + + if !exists { + return createTemplate(c) + } + + return updateTemplate(c) +} + +func createTemplate(c echo.Context) error { + db := service.Database(c) + name := c.Param("templateName") + + var body createTemplateRequestBody + err := json.NewDecoder(c.Request().Body).Decode(&body) + if err != nil { + return err + } + + tx, err := db.BeginTx(c.Request().Context(), nil) + if err != nil { + return err + } + + createdTemplate, err := createDockerTemplate(c.Request().Context(), tx, createTemplateOptions{ + name: name, + description: body.Description, + }) + if err != nil { + _ = tx.Rollback() + return err + } + + if err = tx.Commit(); err != nil { + _ = tx.Rollback() + return err + } + + return c.JSON(http.StatusOK, createdTemplate) +} + +func updateTemplate(c echo.Context) error { + db := service.Database(c) + name := c.Param("templateName") + + var body updateTemplateRequestBody + err := json.NewDecoder(c.Request().Body).Decode(&body) + if err != nil { + return err + } + + if body.BuildArgs != nil && body.ImageTag == nil { + return echo.NewHTTPError(http.StatusBadRequest, "Image tag must be specified if buildArgs is passed") + } + + tx, err := db.BeginTx(c.Request().Context(), nil) + if err != nil { + return err + } + + var tmpl template + err = tx.NewSelect().Model(&tmpl). + Where("name = ?", name). + Scan(c.Request().Context()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } + return err + } + + if body.Description != nil { + tmpl.Description = *body.Description + _, err = tx.NewUpdate().Model(&tmpl). + Column("description"). + WherePK(). + Exec(c.Request().Context()) + if err != nil { + _ = tx.Rollback() + return err + } + + if err = tx.Commit(); err != nil { + _ = tx.Rollback() + return err + } + } + + if body.ImageTag != nil { + err = tx.NewSelect().Model(&tmpl.Files). + Where("template_id = ?", tmpl.ID). + Scan(c.Request().Context()) + if err != nil { + _ = tx.Rollback() + return err + } + + docker := service.DockerClient(c) + log, err := buildDockerTemplate(c.Request().Context(), docker, &tmpl, templateBuildOptions{ + imageTag: *body.ImageTag, + buildArgs: body.BuildArgs, + }) + if err != nil { + return err + } + + w := c.Response() + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + scanner := bufio.NewScanner(log) + + var imageID string + + for scanner.Scan() { + t := scanner.Text() + + fmt.Println("DOCKER LOG: ", t) + + var msg map[string]any + err = json.Unmarshal([]byte(t), &msg) + if err != nil { + return err + } + + if stream, ok := msg["stream"].(string); ok { + if _, err = w.Write([]byte(stream)); err != nil { + return err + } + w.Flush() + } else if errmsg, ok := msg["error"].(string); ok { + if _, err = w.Write([]byte(errmsg + "\n")); err != nil { + return err + } + w.Flush() + } else if status, ok := msg["status"].(string); ok { + var text string + if progress, ok := msg["progress"].(string); ok { + text = fmt.Sprintf("%v: %v\n", status, progress) + } else { + text = status + "\n" + } + if _, err = w.Write([]byte(text)); err != nil { + return err + } + w.Flush() + } else if aux, ok := msg["aux"].(map[string]any); ok { + if id, ok := aux["ID"].(string); ok { + imageID = id + } + } + } + + if imageID != "" { + img := TemplateImage{ + TemplateID: tmpl.ID, + ImageTag: *body.ImageTag, + ImageID: imageID, + } + + _, err = tx.NewInsert().Model(&img).Exec(c.Request().Context()) + if err != nil { + _ = tx.Rollback() + return err + } + } + + if err = tx.Commit(); err != nil { + _ = tx.Rollback() + return err + } + + return nil + } + + if err = tx.Commit(); err != nil { + _ = tx.Rollback() + return err + } + + return c.JSON(http.StatusOK, &tmpl) +} + +func deleteTemplate(c echo.Context) error { + templateName := c.Param("templateName") + if templateName == "" { + return echo.NewHTTPError(http.StatusNotFound) + } + + db := service.Database(c) + + tx, err := db.BeginTx(c.Request().Context(), nil) + if err != nil { + return err + } + + res, err := tx.NewDelete().Table("templates"). + Where("name = ?", templateName). + Exec(c.Request().Context()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } + _ = tx.Rollback() + return err + } + + count, err := res.RowsAffected() + if err != nil { + _ = tx.Rollback() + return err + } + + if count != 1 { + _ = tx.Rollback() + return echo.NewHTTPError(http.StatusInternalServerError) + } + + if err = tx.Commit(); err != nil { + _ = tx.Rollback() + return err + } + + return c.NoContent(http.StatusOK) +} + +func fetchTemplateFile(c echo.Context) error { + templateName := c.Param("templateName") + if templateName == "" { + return echo.NewHTTPError(http.StatusNotFound) + } + + filePath := c.Param("filePath") + if filePath == "" { + return echo.NewHTTPError(http.StatusNotFound) + } + + db := service.Database(c) + + var tmpl template + err := db.NewSelect().Model(&tmpl). + Column("id"). + Where("name = ?", templateName). + Scan(c.Request().Context()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } + return err + } + + var file templateFile + err = db.NewSelect().Model(&file). + Where("template_id = ?", tmpl.ID). + Where("file_path = ?", filePath). + Scan(c.Request().Context()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } + return err + } + + return c.Blob(http.StatusOK, "application/octet-stream", file.Content) +} + +func updateTemplateFile(c echo.Context) error { + templateName := c.Param("templateName") + if templateName == "" { + return echo.NewHTTPError(http.StatusNotFound) + } + + filePath := c.Param("filePath") + if filePath == "" { + return echo.NewHTTPError(http.StatusNotFound) + } + + db := service.Database(c) + + tx, err := db.BeginTx(c.Request().Context(), nil) + if err != nil { + return err + } + + var tmpl template + err = tx.NewSelect().Model(&tmpl). + Column("id"). + Where("name = ?", templateName). + Scan(c.Request().Context()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } + return err + } + + newContent, err := io.ReadAll(c.Request().Body) + if err != nil { + return err + } + + _, err = tx.NewUpdate().Table("template_files"). + Set("content = ?", newContent). + Where("template_id = ?", tmpl.ID). + Where("file_path = ?", filePath). + Exec(c.Request().Context()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } + return err + } + + if err = tx.Commit(); err != nil { + _ = tx.Rollback() + return err + } + + return c.NoContent(http.StatusOK) +} + +func fetchAllTemplateImages(c echo.Context) error { + db := service.Database(c) + + var images []TemplateImage + err := db.NewSelect().Model(&images).Scan(c.Request().Context()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return c.JSON(http.StatusOK, make([]TemplateImage, 0)) + } + return err + } + + if len(images) == 0 { + return c.JSON(http.StatusOK, make([]TemplateImage, 0)) + } + + return c.JSON(http.StatusOK, images) +} diff --git a/internal/template/routes.go b/internal/template/routes.go new file mode 100644 index 0000000..ff23751 --- /dev/null +++ b/internal/template/routes.go @@ -0,0 +1,15 @@ +package template + +import ( + "github.com/labstack/echo/v4" +) + +func DefineRoutes(g *echo.Group) { + g.GET("/templates", fetchAllTemplates) + g.GET("/templates/:templateName", fetchTemplate) + g.POST("/templates/:templateName", createOrUpdateTemplate) + g.DELETE("/templates/:templateName", deleteTemplate) + g.GET("/templates/:templateName/:filePath", fetchTemplateFile) + g.POST("/templates/:templateName/:filePath", updateTemplateFile) + g.GET("/template-images", fetchAllTemplateImages) +} diff --git a/internal/template/template.go b/internal/template/template.go new file mode 100644 index 0000000..1736aec --- /dev/null +++ b/internal/template/template.go @@ -0,0 +1,36 @@ +package template + +import ( + "github.com/google/uuid" + "github.com/uptrace/bun" +) + +type template struct { + bun.BaseModel `bun:"table:templates,alias:template"` + + ID uuid.UUID `bun:"type:uuid,pk" json:"-"` + Name string `json:"name"` + Description string `json:"description"` + CreatedOn string `json:"createdOn"` + LastModifiedOn string `json:"lastModifiedOn"` + IsBuilt bool `json:"isBuilt"` + + Files []*templateFile `bun:"rel:has-many,join:id=template_id" json:"-"` + FileMap map[string]*templateFile `bun:"-" json:"files"` +} + +type templateFile struct { + bun.BaseModel `bun:"table:template_files,alias:template_file"` + + TemplateID uuid.UUID `bun:"type:uuid" json:"-"` + FilePath string `json:"path"` + Content []byte `bun:"type:blob" json:"content"` +} + +type TemplateImage struct { + bun.BaseModel `bun:"table:template_images,alias:template_images"` + + TemplateID uuid.UUID `bun:"type:uuid" json:"-"` + ImageTag string `json:"imageTag"` + ImageID string `json:"imageId"` +} diff --git a/internal/workspace/http_handlers.go b/internal/workspace/http_handlers.go new file mode 100644 index 0000000..8e7a8c2 --- /dev/null +++ b/internal/workspace/http_handlers.go @@ -0,0 +1,110 @@ +package workspace + +import ( + "database/sql" + "encoding/json" + "errors" + "github.com/docker/docker/api/types/container" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "net/http" + "tesseract/internal/service" + "tesseract/internal/template" + "time" +) + +type createWorkspaceRequestBody struct { + ImageID string `json:"imageId"` +} + +func fetchAllWorkspaces(c echo.Context) error { + db := service.Database(c) + + var workspaces []workspace + err := db.NewSelect().Model(&workspaces).Scan(c.Request().Context()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return c.JSON(http.StatusOK, make([]workspace, 0)) + } + return err + } + + if len(workspaces) == 0 { + return c.JSON(http.StatusOK, make([]workspace, 0)) + } + + return c.JSON(http.StatusOK, workspaces) +} + +func createWorkspace(c echo.Context) error { + workspaceName := c.Param("workspaceName") + if workspaceName == "" { + return echo.NewHTTPError(http.StatusNotFound) + } + + if !workspaceNameRegex.MatchString(workspaceName) { + return echo.NewHTTPError(http.StatusNotFound) + } + + body := createWorkspaceRequestBody{} + err := json.NewDecoder(c.Request().Body).Decode(&body) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest) + } + + db := service.Database(c) + + tx, err := db.BeginTx(c.Request().Context(), nil) + if err != nil { + return err + } + + var img template.TemplateImage + err = tx.NewSelect().Model(&img). + Where("image_id = ?", body.ImageID). + Scan(c.Request().Context()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return echo.NewHTTPError(http.StatusBadRequest, "image id not found") + } + return err + } + + docker := service.DockerClient(c) + + res, err := docker.ContainerCreate(c.Request().Context(), &container.Config{ + Image: img.ImageID, + }, nil, nil, nil, workspaceName) + if err != nil { + return err + } + + err = docker.ContainerStart(c.Request().Context(), res.ID, container.StartOptions{}) + if err != nil { + return err + } + + id, err := uuid.NewV7() + if err != nil { + return err + } + + w := workspace{ + ID: id, + Name: workspaceName, + ContainerID: res.ID, + ImageTag: img.ImageTag, + CreatedAt: time.Now().Format(time.RFC3339), + } + _, err = tx.NewInsert().Model(&w).Exec(c.Request().Context()) + if err != nil { + _ = tx.Rollback() + return err + } + + if err = tx.Commit(); err != nil { + return err + } + + return c.JSON(http.StatusOK, w) +} diff --git a/internal/workspace/routes.go b/internal/workspace/routes.go new file mode 100644 index 0000000..f8e1649 --- /dev/null +++ b/internal/workspace/routes.go @@ -0,0 +1,10 @@ +package workspace + +import ( + "github.com/labstack/echo/v4" +) + +func DefineRoutes(g *echo.Group) { + g.GET("/workspaces", fetchAllWorkspaces) + g.POST("/workspaces/:workspaceName", createWorkspace) +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go new file mode 100644 index 0000000..3cecc53 --- /dev/null +++ b/internal/workspace/workspace.go @@ -0,0 +1,24 @@ +package workspace + +import ( + "github.com/google/uuid" + "github.com/uptrace/bun" + "regexp" +) + +type workspace struct { + bun.BaseModel `bun:"table:workspaces,alias:workspace"` + + ID uuid.UUID `bun:",type:uuid,pk"` + + Name string `json:"name"` + + // containerId is the ID of the docker container + ContainerID string `json:"containerId"` + + ImageTag string `json:"imageTag"` + + CreatedAt string `json:"createdAt"` +} + +var workspaceNameRegex = regexp.MustCompile("^[\\w-]+$") diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..7ceb59f --- /dev/null +++ b/web/.gitignore @@ -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? +.env diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..74872fd --- /dev/null +++ b/web/README.md @@ -0,0 +1,50 @@ +# 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/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default tseslint.config({ + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` +- Optionally add `...tseslint.configs.stylisticTypeChecked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: + +```js +// eslint.config.js +import react from 'eslint-plugin-react' + +export default tseslint.config({ + // Set the react version + settings: { react: { version: '18.3' } }, + plugins: { + // Add the react plugin + react, + }, + rules: { + // other rules... + // Enable its recommended rules + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + }, +}) +``` diff --git a/web/biome.json b/web/biome.json new file mode 100644 index 0000000..7af6dfe --- /dev/null +++ b/web/biome.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "ignore": [] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "indentWidth": 2 + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/web/bun.lockb b/web/bun.lockb new file mode 100755 index 0000000..3f1870a Binary files /dev/null and b/web/bun.lockb differ diff --git a/web/components.json b/web/components.json new file mode 100644 index 0000000..cd5af28 --- /dev/null +++ b/web/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..3cc9bde --- /dev/null +++ b/web/package.json @@ -0,0 +1,78 @@ +{ + "name": "tesseract-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@codemirror/lang-markdown": "^6.3.1", + "@codemirror/language": "^6.10.3", + "@codemirror/legacy-modes": "^6.4.2", + "@codemirror/state": "^6.4.1", + "@codemirror/theme-one-dark": "^6.1.2", + "@hookform/resolvers": "^3.9.1", + "@monaco-editor/react": "^4.6.0", + "@radix-ui/react-alert-dialog": "^1.1.2", + "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-collapsible": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-icons": "^1.3.1", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-navigation-menu": "^1.2.1", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-toast": "^1.2.2", + "@radix-ui/react-tooltip": "^1.1.3", + "@replit/codemirror-vim": "^6.2.1", + "@shikijs/monaco": "^1.22.2", + "@tanstack/react-router": "^1.78.3", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "codemirror": "^6.0.1", + "dayjs": "^1.11.13", + "lucide-react": "^0.454.0", + "monaco-editor": "^0.52.0", + "next-themes": "^0.4.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.53.1", + "shiki": "^1.22.2", + "sonner": "^1.7.0", + "superstruct": "^2.0.2", + "swr": "^2.2.5", + "tailwind-merge": "^2.5.4", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.8", + "zustand": "^5.0.1" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@eslint/js": "^9.13.0", + "@tanstack/router-devtools": "^1.78.3", + "@types/node": "^22.8.7", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "eslint": "^9.13.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.11.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "~5.6.2", + "typescript-eslint": "^8.11.0", + "vite": "^5.4.10" + }, + "trustedDependencies": [ + "@biomejs/biome" + ] +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/web/public/vite.svg b/web/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/web/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..30b8148 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,13 @@ +import {Outlet} from "@tanstack/react-router"; +import {TanStackRouterDevtools} from "@tanstack/router-devtools"; + +function App() { + return ( + <> + + + + ); +} + +export default App; diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..e9efb12 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,31 @@ +import { promiseOrThrow } from "./lib/errors"; + +enum ApiError { + NotFound = "NOT_FOUND", + BadRequest = "BAD_REQUEST", + Internal = "INTERNAL", + Network = "NETWORK", +} + +async function fetchApi( + url: URL | RequestInfo, + init?: RequestInit, +): Promise { + const res = await promiseOrThrow( + fetch(`${import.meta.env.VITE_API_URL}/api${url}`, init), + () => ApiError.Network, + ); + if (res.status !== 200) { + switch (res.status) { + case 401: + throw ApiError.BadRequest; + case 404: + throw ApiError.NotFound; + default: + throw ApiError.Internal; + } + } + return res; +} + +export { ApiError, fetchApi }; diff --git a/web/src/assets/font/Geist-Black.woff2 b/web/src/assets/font/Geist-Black.woff2 new file mode 100644 index 0000000..32ccbc9 Binary files /dev/null and b/web/src/assets/font/Geist-Black.woff2 differ diff --git a/web/src/assets/font/Geist-Bold.woff2 b/web/src/assets/font/Geist-Bold.woff2 new file mode 100644 index 0000000..cb112fb Binary files /dev/null and b/web/src/assets/font/Geist-Bold.woff2 differ diff --git a/web/src/assets/font/Geist-ExtraBold.woff2 b/web/src/assets/font/Geist-ExtraBold.woff2 new file mode 100644 index 0000000..0c8065c Binary files /dev/null and b/web/src/assets/font/Geist-ExtraBold.woff2 differ diff --git a/web/src/assets/font/Geist-ExtraLight.woff2 b/web/src/assets/font/Geist-ExtraLight.woff2 new file mode 100644 index 0000000..d42a9be Binary files /dev/null and b/web/src/assets/font/Geist-ExtraLight.woff2 differ diff --git a/web/src/assets/font/Geist-Light.woff2 b/web/src/assets/font/Geist-Light.woff2 new file mode 100644 index 0000000..f67fafe Binary files /dev/null and b/web/src/assets/font/Geist-Light.woff2 differ diff --git a/web/src/assets/font/Geist-Medium.woff2 b/web/src/assets/font/Geist-Medium.woff2 new file mode 100644 index 0000000..9f5717d Binary files /dev/null and b/web/src/assets/font/Geist-Medium.woff2 differ diff --git a/web/src/assets/font/Geist-Regular.woff2 b/web/src/assets/font/Geist-Regular.woff2 new file mode 100644 index 0000000..a7b2ff8 Binary files /dev/null and b/web/src/assets/font/Geist-Regular.woff2 differ diff --git a/web/src/assets/font/Geist-SemiBold.woff2 b/web/src/assets/font/Geist-SemiBold.woff2 new file mode 100644 index 0000000..91d63eb Binary files /dev/null and b/web/src/assets/font/Geist-SemiBold.woff2 differ diff --git a/web/src/assets/font/Geist-Thin.woff2 b/web/src/assets/font/Geist-Thin.woff2 new file mode 100644 index 0000000..d4dbf54 Binary files /dev/null and b/web/src/assets/font/Geist-Thin.woff2 differ diff --git a/web/src/assets/react.svg b/web/src/assets/react.svg new file mode 100644 index 0000000..f4b5956 --- /dev/null +++ b/web/src/assets/react.svg @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/web/src/components/codemirror-editor.tsx b/web/src/components/codemirror-editor.tsx new file mode 100644 index 0000000..6c88d7b --- /dev/null +++ b/web/src/components/codemirror-editor.tsx @@ -0,0 +1,137 @@ +import { markdown } from "@codemirror/lang-markdown"; +import { oneDark } from "@codemirror/theme-one-dark"; +import { StreamLanguage } from "@codemirror/language"; +import { dockerFile } from "@codemirror/legacy-modes/mode/dockerfile"; +import { + Compartment, + EditorState, + type Extension, + type StateEffect, +} from "@codemirror/state"; +import { vim } from "@replit/codemirror-vim"; +import { EditorView, basicSetup } from "codemirror"; +import { useEffect, useRef } from "react"; +import { useUiMode } from "@/hooks/use-ui-mode"; + +type CodeMirrorEditorSupportedLanguage = "markdown" | "dockerfile"; + +interface CodeMirrorEditorProps { + path: string; + initialValue: string; + className?: string; + onValueChanged?: (path: string, value: string) => void; +} + +function languageExtensionFrom(path: string) { + const basename = path.split("/").at(-1); + const ext = path.split(".").at(-1); + + if (basename === "Dockerfile") { + return StreamLanguage.define(dockerFile); + } + + switch (ext) { + case "md": + return markdown(); + default: + return null; + } +} + +const baseEditorTheme = EditorView.theme({ + "&": { + height: "100%", + background: "hsl(var(--background))", + }, + "& .cm-gutters": { + background: "hsl(var(--background))", + }, +}); + +function CodeMirrorEditor({ + path, + initialValue, + onValueChanged, + className, +}: CodeMirrorEditorProps) { + const editorElRef = useRef(null); + const editorStates = useRef>(new Map()); + const editorViewRef = useRef(null); + const uiMode = useUiMode(); + const editorThemeCompartment = useRef(new Compartment()); + + // biome-ignore lint/correctness/useExhaustiveDependencies: this only needs to be called once. + useEffect(() => { + if (editorElRef.current && !editorViewRef.current) { + const state = createEditorState(path, initialValue); + editorStates.current.set(path, state); + editorViewRef.current = new EditorView({ + state, + parent: editorElRef.current, + }); + } + return () => { + editorViewRef.current?.destroy(); + editorViewRef.current = null; + }; + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: only need to be called when path changes because initialValue isn't reactive + useEffect(() => { + if (!editorViewRef.current) return; + + let newState = editorStates.current.get(path); + if (!newState) { + newState = createEditorState(path, initialValue); + editorStates.current.set(path, newState); + } + + editorViewRef.current.setState(newState); + }, [path]); + + function createEditorState(path: string, initialValue: string) { + const exts: Extension[] = [ + basicSetup, + baseEditorTheme, + editorThemeCompartment.current.of(uiMode === "light" ? [] : oneDark), + vim(), + EditorView.updateListener.of((update) => { + editorStates.current.set(path, update.state); + if (update.docChanged) { + onValueChanged?.(path, update.state.doc.toString()); + } + }), + ]; + + const language = languageExtensionFrom(path); + if (language) { + exts.push(language); + } + + return EditorState.create({ + doc: initialValue, + extensions: exts, + }); + } + + useEffect(() => { + let effect: StateEffect; + switch (uiMode) { + case "light": + effect = editorThemeCompartment.current.reconfigure([]); + break; + case "dark": + effect = editorThemeCompartment.current.reconfigure(oneDark); + break; + } + + editorViewRef.current?.dispatch({ + effects: effect, + }); + }, [uiMode]); + + return
; +} + +export { CodeMirrorEditor }; +export type { CodeMirrorEditorProps, CodeMirrorEditorSupportedLanguage }; diff --git a/web/src/components/main-sidebar.tsx b/web/src/components/main-sidebar.tsx new file mode 100644 index 0000000..62ff838 --- /dev/null +++ b/web/src/components/main-sidebar.tsx @@ -0,0 +1,50 @@ +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarSeparator, +} from "@/components/ui/sidebar.tsx"; +import { Link } from "@tanstack/react-router"; +import { LayoutPanelLeft, ScrollText } from "lucide-react"; + +function MainSidebar() { + return ( + + +
+

Tesseract

+

v0.1.0

+
+
+ + + + + + + + + Workspaces + + + + + + + + Templates + + + + + + +
+ ); +} + +export { MainSidebar }; diff --git a/web/src/components/ui/alert-dialog.tsx b/web/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..d2e6d48 --- /dev/null +++ b/web/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import {cn} from "@/lib/utils" +import {buttonVariants} from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/web/src/components/ui/alert.tsx b/web/src/components/ui/alert.tsx new file mode 100644 index 0000000..5afd41d --- /dev/null +++ b/web/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/web/src/components/ui/avatar.tsx b/web/src/components/ui/avatar.tsx new file mode 100644 index 0000000..991f56e --- /dev/null +++ b/web/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..e87d62b --- /dev/null +++ b/web/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/web/src/components/ui/breadcrumb.tsx b/web/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..cfdc9ac --- /dev/null +++ b/web/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons" +import { Slot } from "@radix-ui/react-slot" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>