chore: added model delete related to #2

This commit is contained in:
Andre Henriques 2023-09-21 15:38:02 +01:00
parent af62db6ad1
commit b8278bacf6
21 changed files with 1264 additions and 185 deletions

View File

@ -7,7 +7,7 @@ tmp_dir = "tmp"
bin = "./tmp/main" bin = "./tmp/main"
cmd = "go build -o ./tmp/main ." cmd = "go build -o ./tmp/main ."
delay = 0 delay = 0
exclude_dir = ["assets", "tmp", "vendor", "testdata"] exclude_dir = ["assets", "tmp", "vendor", "testData", "savedData"]
exclude_file = [] exclude_file = []
exclude_regex = ["_test.go"] exclude_regex = ["_test.go"]
exclude_unchanged = false exclude_unchanged = false

3
.gitignore vendored
View File

@ -1 +1,4 @@
tmp/ tmp/
testData/
savedData/
!savedData/.keep

1
go.mod
View File

@ -3,6 +3,7 @@ module andr3h3nriqu3s.com/m
go 1.20 go 1.20
require ( require (
github.com/google/uuid v1.3.1 // indirect
github.com/lib/pq v1.10.9 // indirect github.com/lib/pq v1.10.9 // indirect
golang.org/x/crypto v0.13.0 // indirect golang.org/x/crypto v0.13.0 // indirect
) )

2
go.sum
View File

@ -1,3 +1,5 @@
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=

View File

@ -8,6 +8,8 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"os"
"path"
"strings" "strings"
"time" "time"
) )
@ -78,11 +80,56 @@ func LoadHtml(writer http.ResponseWriter, path string, data interface{}) {
} }
} }
func LoadError(writer http.ResponseWriter, path string, base string, data AnyMap) {
if data == nil {
data = map[string]interface{} {
"Error": true,
}
} else {
data["Error"] = true
}
tmpl, err := template.New("").Parse("{{template \"" + base + "\" . }}")
if err != nil {
panic("Lol")
}
tmpl, err = tmpl.ParseFiles(
"./views/"+path,
"./views/partials/header.html",
)
if err != nil {
fmt.Printf("Failed to load template %s\n", path)
fmt.Println(err)
writer.WriteHeader(http.StatusInternalServerError)
if path == "500.html" {
writer.Write([]byte("<h1>Failed to load 500.html check console for more info</h1>"))
} else {
LoadHtml(writer, "500.html", nil)
}
return
}
if err := tmpl.Execute(writer, data); err != nil {
fmt.Printf("Failed to execute template %s\n", path)
fmt.Println(err)
writer.WriteHeader(http.StatusInternalServerError)
if path == "500.html" {
writer.Write([]byte("<h1>Failed to load 500.html check console for more info</h1>"))
} else {
LoadHtml(writer, "500.html", nil)
}
return
}
}
type AnyMap = map[string]interface{} type AnyMap = map[string]interface{}
type Error struct { type Error struct {
code int code int
msg *string msg *string
data AnyMap
} }
type AnswerType int type AnswerType int
@ -133,9 +180,10 @@ type Handler interface {
} }
type Handle struct { type Handle struct {
db *sql.DB db *sql.DB
gets []HandleFunc gets []HandleFunc
posts []HandleFunc posts []HandleFunc
deletes []HandleFunc
} }
func decodeBody(r *http.Request) (string, *Error) { func decodeBody(r *http.Request) (string, *Error) {
@ -149,9 +197,8 @@ func decodeBody(r *http.Request) (string, *Error) {
func handleError(err *Error, w http.ResponseWriter, context *Context) { func handleError(err *Error, w http.ResponseWriter, context *Context) {
data := context.toMap()
if err != nil { if err != nil {
data := context.addMap(err.data)
w.WriteHeader(err.code) w.WriteHeader(err.code)
if err.code == http.StatusNotFound { if err.code == http.StatusNotFound {
LoadBasedOnAnswer(context.Mode, w, "404.html", data) LoadBasedOnAnswer(context.Mode, w, "404.html", data)
@ -168,69 +215,39 @@ func handleError(err *Error, w http.ResponseWriter, context *Context) {
} }
func (x *Handle) Get(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) { func (x *Handle) Get(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) {
nhandler := x.gets = append(x.gets, HandleFunc{path, NORMAL | HTML | HTMLFULL | JSON, fn})
HandleFunc{
fn: fn,
path: path,
mode: NORMAL | HTML | HTMLFULL | JSON,
}
x.gets = append(x.gets, nhandler)
} }
func (x *Handle) GetHTML(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) { func (x *Handle) GetHTML(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) {
nhandler := x.gets = append(x.gets, HandleFunc{path, NORMAL | HTML | HTMLFULL, fn})
HandleFunc{
fn: fn,
path: path,
mode: NORMAL | HTML | HTMLFULL,
}
x.gets = append(x.gets, nhandler)
} }
func (x *Handle) GetJSON(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) { func (x *Handle) GetJSON(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) {
nhandler := x.gets = append(x.gets, HandleFunc{path, JSON, fn})
HandleFunc{
fn: fn,
path: path,
mode: JSON,
}
x.gets = append(x.gets, nhandler)
} }
func (x *Handle) Post(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) { func (x *Handle) Post(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) {
nhandler := x.posts = append(x.posts, HandleFunc{path, NORMAL | HTML | HTMLFULL | JSON, fn})
HandleFunc{
fn: fn,
path: path,
mode: NORMAL | HTML | HTMLFULL | JSON,
}
x.posts = append(x.posts, nhandler)
} }
func (x *Handle) PostHTML(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) { func (x *Handle) PostHTML(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) {
nhandler := x.posts = append(x.posts, HandleFunc{path, NORMAL | HTML | HTMLFULL, fn})
HandleFunc{
fn: fn,
path: path,
mode: NORMAL | HTML | HTMLFULL,
}
x.posts = append(x.posts, nhandler)
} }
func (x *Handle) PostJSON(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) { func (x *Handle) PostJSON(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) {
nhandler := x.posts = append(x.posts, HandleFunc{path, JSON, fn})
HandleFunc{ }
fn: fn,
path: path,
mode: JSON,
}
x.posts = append(x.posts, nhandler) func (x *Handle) Delete(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) {
x.deletes = append(x.deletes, HandleFunc{path, NORMAL | HTML | HTMLFULL | JSON, fn})
}
func (x *Handle) DeleteHTML(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) {
x.deletes = append(x.deletes, HandleFunc{path, NORMAL | HTML | HTMLFULL, fn})
}
func (x *Handle) DeleteJSON(path string, fn func(w http.ResponseWriter, r *http.Request, c *Context) *Error) {
x.deletes = append(x.deletes, HandleFunc{path, JSON, fn})
} }
func (x *Handle) handleGets(w http.ResponseWriter, r *http.Request, context *Context) { func (x *Handle) handleGets(w http.ResponseWriter, r *http.Request, context *Context) {
@ -243,9 +260,7 @@ func (x *Handle) handleGets(w http.ResponseWriter, r *http.Request, context *Con
if context.Mode != HTMLFULL { if context.Mode != HTMLFULL {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
} }
LoadBasedOnAnswer(context.Mode, w, "404.html", map[string]interface{}{ LoadBasedOnAnswer(context.Mode, w, "404.html", context.addMap(nil))
"context": context,
})
} }
func (x *Handle) handlePosts(w http.ResponseWriter, r *http.Request, context *Context) { func (x *Handle) handlePosts(w http.ResponseWriter, r *http.Request, context *Context) {
@ -258,18 +273,42 @@ func (x *Handle) handlePosts(w http.ResponseWriter, r *http.Request, context *Co
if context.Mode != HTMLFULL { if context.Mode != HTMLFULL {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
} }
LoadBasedOnAnswer(context.Mode, w, "404.html", map[string]interface{}{ LoadBasedOnAnswer(context.Mode, w, "404.html", context.addMap(nil))
"context": context,
})
} }
func AnswerTemplate(path string, data AnyMap) func(w http.ResponseWriter, r *http.Request, c *Context) *Error { func (x *Handle) handleDeletes(w http.ResponseWriter, r *http.Request, context *Context) {
for _, s := range x.deletes {
if s.path == r.URL.Path && context.Mode&s.mode != 0 {
handleError(s.fn(w, r, context), w, context)
return
}
}
if context.Mode != HTMLFULL {
w.WriteHeader(http.StatusNotFound)
}
LoadBasedOnAnswer(context.Mode, w, "404.html", context.addMap(nil))
}
func checkAuthLevel(authLevel int, w http.ResponseWriter, r *http.Request, c *Context) bool {
if authLevel > 0 {
if c.requireAuth(w, r) {
logoff(c.Mode, w, r)
return false
}
if c.User.user_type < authLevel {
notAuth(c.Mode, w, r)
return false
}
}
return true
}
func AnswerTemplate(path string, data AnyMap, authLevel int) func(w http.ResponseWriter, r *http.Request, c *Context) *Error {
return func(w http.ResponseWriter, r *http.Request, c *Context) *Error { return func(w http.ResponseWriter, r *http.Request, c *Context) *Error {
if data == nil { if !checkAuthLevel(authLevel, w, r, c) {
LoadBasedOnAnswer(c.Mode, w, path, c.toMap()) return nil
} else { }
LoadBasedOnAnswer(c.Mode, w, path, c.addMap(data)) LoadBasedOnAnswer(c.Mode, w, path, c.addMap(data))
}
return nil return nil
} }
} }
@ -277,25 +316,24 @@ func AnswerTemplate(path string, data AnyMap) func(w http.ResponseWriter, r *htt
type Context struct { type Context struct {
Token *string Token *string
User *User User *User
Mode AnswerType Mode AnswerType
} }
func (c Context) addMap(m AnyMap) AnyMap { func (c Context) addMap(m AnyMap) AnyMap {
m["Context"] = c; if m == nil {
return m; return map[string]interface{}{
} "Context": c,
}
func (c *Context) toMap() map[string]interface{} {
return map[string]interface{}{
"Context": c,
} }
m["Context"] = c
return m
} }
func (c *Context) requireAuth(w http.ResponseWriter, r *http.Request) bool { func (c *Context) requireAuth(w http.ResponseWriter, r *http.Request) bool {
if c.User == nil { if c.User == nil {
return true; return true
} }
return false; return false
} }
var LogoffError = errors.New("Invalid token!") var LogoffError = errors.New("Invalid token!")
@ -310,10 +348,12 @@ func (x Handle) createContext(mode AnswerType, r *http.Request) (*Context, error
} }
} }
// TODO check that the token is still valid
if token == nil { if token == nil {
return &Context{ return &Context{
Mode: mode, Mode: mode,
}, nil }, nil
} }
user, err := userFromToken(x.db, *token) user, err := userFromToken(x.db, *token)
@ -324,35 +364,114 @@ func (x Handle) createContext(mode AnswerType, r *http.Request) (*Context, error
return &Context{token, user, mode}, nil return &Context{token, user, mode}, nil
} }
func logoff(mode AnswerType, w http.ResponseWriter, r *http.Request) { // TODO check if I can use http.Redirect
// Delete cookie func redirect(path string, mode AnswerType, w http.ResponseWriter, r *http.Request) {
cookie := &http.Cookie{ w.Header().Set("Location", path)
Name: "auth", if mode == JSON {
Value: "", w.WriteHeader(http.StatusSeeOther)
Expires: time.Unix(0, 0), w.Write([]byte(path))
} return
http.SetCookie(w, cookie) }
if mode&(HTMLFULL|HTML) != 0 {
w.WriteHeader(http.StatusSeeOther)
w.Write([]byte(path))
} else {
w.WriteHeader(http.StatusSeeOther)
}
}
// Setup response func logoff(mode AnswerType, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Location", "/login") // Delete cookie
if mode == JSON { cookie := &http.Cookie{
w.WriteHeader(http.StatusUnauthorized) Name: "auth",
w.Write([]byte("\"Bye Bye\"")); Value: "",
return Expires: time.Unix(0, 0),
} }
if mode & (HTMLFULL | HTML) != 0 { http.SetCookie(w, cookie)
w.WriteHeader(http.StatusUnauthorized); redirect("/login", mode, w, r)
w.Write([]byte("Bye Bye")); }
} else {
w.WriteHeader(http.StatusSeeOther); func notAuth(mode AnswerType, w http.ResponseWriter, r *http.Request) {
} if mode == JSON {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("\"You can not access this resource!\""))
return
}
if mode&(HTMLFULL|HTML) != 0 {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("You can not access this resource!"))
} else {
w.WriteHeader(http.StatusForbidden)
}
}
func (x Handle) staticFiles(pathTest string, fileType string, contentType string) {
http.HandleFunc(pathTest, func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path[len(pathTest):]
if !strings.HasSuffix(path, fileType) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("File not found"))
return
}
t, err := template.ParseFiles("./views" + pathTest + path)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Failed to load template"))
return
}
w.Header().Set("Content-Type", contentType+"; charset=utf-8")
t.Execute(w, nil)
})
}
func errorCode(err error, code int, data AnyMap) *Error {
// TODO Improve Logging
if err != nil {
fmt.Printf("Something went wrong returning with: %d\n.Err:\n", code)
fmt.Println(err)
}
return &Error{code, nil, data}
}
func error500(err error) *Error {
return errorCode(err, http.StatusInternalServerError, nil)
}
func (x Handle) readFiles(pathTest string, baseFilePath string, fileType string, contentType string) {
http.HandleFunc(pathTest, func(w http.ResponseWriter, r *http.Request) {
user_path := r.URL.Path[len(pathTest):]
fmt.Printf("Requested path: %s\n", user_path)
if !strings.HasSuffix(user_path, fileType) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("File not found"))
return
}
bytes, err := os.ReadFile(path.Join(baseFilePath, pathTest, user_path))
if err != nil {
fmt.Println(err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Failed to load file"))
return
}
w.Header().Set("Content-Type", contentType)
w.Write(bytes)
})
} }
func NewHandler(db *sql.DB) *Handle { func NewHandler(db *sql.DB) *Handle {
var gets []HandleFunc var gets []HandleFunc
var posts []HandleFunc var posts []HandleFunc
x := &Handle{ db, gets, posts, } var deletes []HandleFunc
x := &Handle{db, gets, posts, deletes}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Decide answertype // Decide answertype
@ -380,53 +499,11 @@ func NewHandler(db *sql.DB) *Handle {
x.handlePosts(w, r, context) x.handlePosts(w, r, context)
return return
} }
panic("TODO handle: " + r.Method) if r.Method == "DELETE" {
}) x.handleDeletes(w, r, context)
// TODO Handle this in other way
http.HandleFunc("/styles/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path[len("/styles/"):]
if !strings.HasSuffix(path, ".css") {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("File not found"))
return return
} }
panic("TODO handle method: " + r.Method)
t, err := template.ParseFiles("./views/styles/" + path)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Failed to load template"))
fmt.Println("Error:")
fmt.Println(err)
return
}
w.Header().Set("Content-Type", "text/css; charset=utf-8")
t.Execute(w, nil)
})
// TODO Handle this in other way
http.HandleFunc("/js/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path[len("/js/"):]
if !strings.HasSuffix(path, ".js") {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("File not found"))
return
}
t, err := template.ParseFiles("./views/js/" + path)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Failed to load template"))
fmt.Println("Error:")
fmt.Println(err)
return
}
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
t.Execute(w, nil)
}) })
return x return x

14
main.go
View File

@ -1,8 +1,8 @@
package main package main
import ( import (
"fmt"
"database/sql" "database/sql"
"fmt"
_ "github.com/lib/pq" _ "github.com/lib/pq"
) )
@ -27,11 +27,21 @@ func main() {
defer db.Close() defer db.Close()
fmt.Println("Starting server on :8000!") fmt.Println("Starting server on :8000!")
//TODO check if file structure exists to save data
handle := NewHandler(db) handle := NewHandler(db)
handle.GetHTML("/", AnswerTemplate("index.html", nil))
// TODO Handle this in other way
handle.staticFiles("/styles/", ".css", "text/css");
handle.staticFiles("/js/", ".js", "text/javascript");
handle.readFiles("/imgs/", "views", ".png", "image/png;");
handle.readFiles("/savedData/", ".", ".png", "image/png;");
handle.GetHTML("/", AnswerTemplate("index.html", nil, 0))
usersEndpints(db, handle) usersEndpints(db, handle)
handleModelsEndpoints(handle)
handle.Startup() handle.Startup()
} }

405
models.go Normal file
View File

@ -0,0 +1,405 @@
package main
import (
"bytes"
"fmt"
"image"
"image/color"
_ "image/png"
"io"
"net/http"
"os"
"path"
"strconv"
)
const (
FAILED_PREPARING = -1
PREPARING = iota
CONFIRM_PRE_TRAINING
)
type BaseModel struct {
Name string
Status int
Id string
}
func modelUpdateStatus(handle *Handle, id string, status int) {
_, err := handle.db.Exec("update models set status = $1 where id = $2", status, id)
if err != nil {
fmt.Println("Failed to update model status")
fmt.Println(err)
panic("TODO handle better")
}
}
func loadBaseImage(handle *Handle, id string) {
// TODO handle more types than png
infile, err := os.Open(path.Join("savedData", id, "baseimage.png"))
if err != nil {
// TODO better logging
fmt.Println(err)
fmt.Printf("Failed to read image for model with id %s\n", id)
modelUpdateStatus(handle, id, -1)
return
}
defer infile.Close()
src, format, err := image.Decode(infile)
if err != nil {
// TODO better logging
fmt.Println(err)
fmt.Printf("Failed to load image for model with id %s\n", id)
modelUpdateStatus(handle, id, -1)
return
}
if format != "png" {
// TODO better logging
fmt.Printf("Found unkown format '%s'\n", format)
panic("Handle diferent files than .png")
}
var model_color string
bounds := src.Bounds()
width, height := bounds.Max.X, bounds.Max.Y
switch src.ColorModel() {
case color.Gray16Model:
fallthrough
case color.GrayModel:
model_color = "greyscale"
default:
fmt.Println("Do not know how to handle this color model")
if src.ColorModel() == color.RGBA64Model {
fmt.Println("Color is rgb")
} else if src.ColorModel() == color.NRGBAModel {
fmt.Println("Color is nrgb")
} else if src.ColorModel() == color.YCbCrModel {
fmt.Println("Color is ycbcr")
} else if src.ColorModel() == color.AlphaModel {
fmt.Println("Color is alpha")
} else if src.ColorModel() == color.CMYKModel {
fmt.Println("Color is cmyk")
} else {
fmt.Println("Other so assuming color")
}
modelUpdateStatus(handle, id, -1)
return
}
// Note: this also updates the status to 2
_, err = handle.db.Exec("update models set width=$1, height=$2, color_mode=$3, status=$4 where id=$5", width, height, model_color, CONFIRM_PRE_TRAINING, id)
if err != nil {
// TODO better logging
fmt.Println(err)
fmt.Printf("Could not update model\n")
modelUpdateStatus(handle, id, -1)
return
}
}
func deleteModel(handle *Handle, id string, w http.ResponseWriter, c *Context, model BaseModel) {
fmt.Printf("Removing model with id: %s\n", id)
_, err := handle.db.Exec("delete from models where id=$1;", id)
if err != nil {
fmt.Println(err)
panic("TODO handle better deleteModel failed delete database query")
}
model_path := path.Join("./savedData", id)
fmt.Printf("Removing folder of model with id: %s at %s\n", id, model_path)
err = os.RemoveAll(model_path)
if err != nil {
fmt.Println(err)
panic("TODO handle better deleteModel failed to delete folder")
}
if c.Mode == HTML {
// TODO move this to a constant so i don't forget
w.WriteHeader(309)
c.Mode = HTMLFULL
}
LoadBasedOnAnswer(c.Mode, w, "/models/delete.html", c.addMap(AnyMap{
"Model": model,
}))
}
func handleModelsEndpoints(handle *Handle) {
// TODO json
handle.GetHTML("/models", func(w http.ResponseWriter, r *http.Request, c *Context) *Error {
if c.Mode == JSON {
panic("TODO JSON")
}
if !checkAuthLevel(1, w, r, c) {
return nil
}
rows, err := handle.db.Query("select id, name from models where user_id=$1;", c.User.id)
if err != nil {
return error500(err)
}
type row struct {
Name string
Id string
}
got := []row{}
for rows.Next() {
var r row
err = rows.Scan(&r.Id, &r.Name)
if err != nil {
return error500(err)
}
got = append(got, r)
}
LoadBasedOnAnswer(c.Mode, w, "/models/list.html", c.addMap(AnyMap{
"List": got,
}))
return nil
})
handle.GetHTML("/models/add", AnswerTemplate("models/add.html", nil, 1))
// TODO json
handle.Post("/models/add", func(w http.ResponseWriter, r *http.Request, c *Context) *Error {
if c.Mode == JSON {
panic("TODO JSON")
}
if !checkAuthLevel(1, w, r, c) {
return nil
}
read_form, err := r.MultipartReader()
if err != nil {
LoadBasedOnAnswer(c.Mode, w, "models/add.html", c.addMap(nil))
return nil
}
var name string
var file []byte
for {
part, err_part := read_form.NextPart()
if err_part == io.EOF {
break
} else if err_part != nil {
return &Error{code: http.StatusBadRequest}
}
if part.FormName() == "name" {
buf := new(bytes.Buffer)
buf.ReadFrom(part)
name = buf.String()
}
if part.FormName() == "file" {
buf := new(bytes.Buffer)
buf.ReadFrom(part)
file = buf.Bytes()
}
}
if name == "" || len(file) == 0 {
LoadBasedOnAnswer(c.Mode, w, "models/add.html", c.addMap(nil))
return nil
}
row, err := handle.db.Query("select id from models where name=$1 and user_id=$2;", name, c.User.id)
if err != nil {
return error500(err)
}
if row.Next() {
LoadBasedOnAnswer(c.Mode, w, "models/add.html", c.addMap(AnyMap{
"NameFoundError": true,
"Name": name,
}))
return nil
}
_, err = handle.db.Exec("insert into models (user_id, name) values ($1, $2)", c.User.id, name)
if err != nil {
return error500(err)
}
row, err = handle.db.Query("select id from models where name=$1 and user_id=$2;", name, c.User.id)
if err != nil {
return error500(err)
}
if !row.Next() {
return &Error{code: http.StatusInternalServerError}
}
var id string
err = row.Scan(&id)
if err != nil {
return error500(err)
}
// TODO mk this path configurable
dir_path := path.Join("savedData", id)
err = os.Mkdir(dir_path, os.ModePerm)
if err != nil {
return error500(err)
}
f, err := os.Create(path.Join(dir_path, "baseimage.png"))
if err != nil {
return error500(err)
}
defer f.Close()
f.Write(file)
fmt.Printf("Created model with id %s! Started to proccess image!\n", id)
go loadBaseImage(handle, id)
redirect("/models/edit?id="+id, c.Mode, w, r)
return nil
})
handle.GetHTML("/models/edit", func(w http.ResponseWriter, r *http.Request, c *Context) *Error {
if !checkAuthLevel(1, w, r, c) {
return nil
}
id, err := getIdFromUrl(r, "id")
if err != nil {
return errorCode(nil, http.StatusNotFound, AnyMap{
"NotFoundMessage": "Model not found",
"GoBackLink": "/models",
})
}
// TODO handle admin users
rows, err := handle.db.Query("select name, status, width, height, color_mode from models where id=$1 and user_id=$2;", id, c.User.id)
if err != nil {
return error500(err)
}
if !rows.Next() {
return errorCode(nil, http.StatusNotFound, AnyMap{
"NotFoundMessage": "Model not found",
"GoBackLink": "/models",
})
}
type rowmodel struct {
Name string
Status int
Id string
Width *int
Height *int
Color_mode *string
}
var model rowmodel = rowmodel{}
model.Id = id
err = rows.Scan(&model.Name, &model.Status, &model.Width, &model.Height, &model.Color_mode)
if err != nil {
return error500(err)
}
// Handle errors
// All errors will be negative
if model.Status < 0 {
LoadBasedOnAnswer(c.Mode, w, "/models/edit.html", c.addMap(AnyMap{
"Model": model,
}))
return nil
}
switch model.Status {
case PREPARING:
LoadBasedOnAnswer(c.Mode, w, "/models/edit.html", c.addMap(AnyMap{
"Model": model,
}))
case CONFIRM_PRE_TRAINING:
LoadBasedOnAnswer(c.Mode, w, "/models/edit.html", c.addMap(AnyMap{
"Model": model,
}))
default:
fmt.Printf("Unkown Status: %d\n", model.Status)
return error500(nil)
}
return nil
})
handle.Delete("/models/delete", func(w http.ResponseWriter, r *http.Request, c *Context) *Error {
if c.Mode == JSON {
panic("TODO handle json on models/delete")
}
f, err := MyParseForm(r)
if err != nil {
return errorCode(err, 400, nil)
}
if !checkId(f, "id") {
return errorCode(nil, http.StatusNotFound, AnyMap{
"NotFoundMessage": "Model not found",
"GoBackLink": "/models",
})
}
id := f.Get("id")
// TODO handle admin users
rows, err := handle.db.Query("select name, status from models where id=$1 and user_id=$2;", id, c.User.id)
if err != nil {
return error500(err)
}
if !rows.Next() {
return errorCode(nil, http.StatusNotFound, AnyMap{
"NotFoundMessage": "Model not found",
"GoBackLink": "/models",
})
}
var model BaseModel = BaseModel{}
model.Id = id
err = rows.Scan(&model.Name, &model.Status)
if err != nil {
return error500(err)
}
switch model.Status {
case FAILED_PREPARING:
deleteModel(handle, id, w, c, model)
return nil
case CONFIRM_PRE_TRAINING:
if checkEmpty(f, "name") {
// TODO improve result
return errorCode(nil, http.StatusBadRequest, nil)
}
name := f.Get("name")
if name != model.Name {
LoadError(w, "/models/edit.html", "delete-model-card", c.addMap(AnyMap{
"NameDoesNotMatch": true,
"Model": model,
}))
return nil
}
deleteModel(handle, id, w, c, model)
return nil
default:
panic("Do not know how to handle model in status:" + strconv.Itoa(model.Status))
}
})
}

View File

@ -4,10 +4,10 @@ in
{ pkgs ? import <nixpkgs> {} }: { pkgs ? import <nixpkgs> {} }:
pkgs.mkShell { pkgs.mkShell {
nativeBuildInputs = with unstable; [ nativeBuildInputs = with unstable; [
go go
gopls gopls
air air
nodePackages.vscode-css-languageserver-bin nodePackages.vscode-css-languageserver-bin
nodePackages.vscode-html-languageserver-bin nodePackages.vscode-html-languageserver-bin
]; ];
} }

20
sql/models.sql Normal file
View File

@ -0,0 +1,20 @@
-- drop table if exists model_defenitions
-- drop table if exists models;
create table if not exists models (
id uuid primary key default gen_random_uuid(),
user_id uuid references users (id) not null,
name varchar (70) not null,
-- Status:
-- -1: failed preparing
-- 1: preparing
status integer default 1,
width integer,
height integer,
color_mode varchar (20)
);
-- create table model_defenitions (
-- id uuid primary key default gen_random_uuid(),
-- model_id uuid references models (id) not null,
-- )

View File

@ -1,8 +1,9 @@
-- drop table if exists tokens; -- drop table if exists tokens;
-- drop table if exists models;
-- drop table if exists users; -- drop table if exists users;
create table if not exists users ( create table if not exists users (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
user_type integer default 0, user_type integer default 1,
username varchar (120) not null, username varchar (120) not null,
email varchar (120) not null, email varchar (120) not null,
salt char (8) not null, salt char (8) not null,

View File

@ -108,7 +108,7 @@ func generateToken(db *sql.DB, email string, password string) (string, bool) {
} }
func usersEndpints(db *sql.DB, handle *Handle) { func usersEndpints(db *sql.DB, handle *Handle) {
handle.GetHTML("/login", AnswerTemplate("login.html", nil)) handle.GetHTML("/login", AnswerTemplate("login.html", nil, 0))
handle.Post("/login", func(w http.ResponseWriter, r *http.Request, c *Context) *Error { handle.Post("/login", func(w http.ResponseWriter, r *http.Request, c *Context) *Error {
if c.Mode == JSON { if c.Mode == JSON {
fmt.Println("Handle JSON") fmt.Println("Handle JSON")
@ -148,7 +148,7 @@ func usersEndpints(db *sql.DB, handle *Handle) {
return nil return nil
}) })
handle.GetHTML("/register", AnswerTemplate("register.html", nil)) handle.GetHTML("/register", AnswerTemplate("register.html", nil, 0))
handle.Post("/register", func(w http.ResponseWriter, r *http.Request, c *Context) *Error { handle.Post("/register", func(w http.ResponseWriter, r *http.Request, c *Context) *Error {
if c.Mode == JSON { if c.Mode == JSON {
return &Error{code: http.StatusNotFound} return &Error{code: http.StatusNotFound}

147
utils.go
View File

@ -1,7 +1,150 @@
package main package main
import "net/url" import (
"errors"
"io"
"mime"
"net/http"
"net/url"
"github.com/google/uuid"
)
func checkEmpty(f url.Values, path string) bool { func checkEmpty(f url.Values, path string) bool {
return !f.Has(path) || f.Get(path) == "" return !f.Has(path) || f.Get(path) == ""
}
func checkId(f url.Values, path string) bool {
return !checkEmpty(f, path) && isValidUUID(f.Get(path))
}
func isValidUUID(u string) bool {
_, err := uuid.Parse(u)
return err == nil
}
func getIdFromUrl(r *http.Request, target string) (string, error) {
if !r.URL.Query().Has(target) {
return "", errors.New("Query does not have " + target)
}
id := r.URL.Query().Get("id")
if len(id) == 0 {
return "", errors.New("Query is empty for " + target)
}
if !isValidUUID(id) {
return "", errors.New("Value of query is not a valid uuid for " + target)
}
return id, nil
}
type maxBytesReader struct {
w http.ResponseWriter
r io.ReadCloser // underlying reader
i int64 // max bytes initially, for MaxBytesError
n int64 // max bytes remaining
err error // sticky error
}
type MaxBytesError struct {
Limit int64
}
func (e *MaxBytesError) Error() string {
// Due to Hyrum's law, this text cannot be changed.
return "http: request body too large"
}
func (l *maxBytesReader) Read(p []byte) (n int, err error) {
if l.err != nil {
return 0, l.err
}
if len(p) == 0 {
return 0, nil
}
// If they asked for a 32KB byte read but only 5 bytes are
// remaining, no need to read 32KB. 6 bytes will answer the
// question of the whether we hit the limit or go past it.
// 0 < len(p) < 2^63
if int64(len(p))-1 > l.n {
p = p[:l.n+1]
}
n, err = l.r.Read(p)
if int64(n) <= l.n {
l.n -= int64(n)
l.err = err
return n, err
}
n = int(l.n)
l.n = 0
// The server code and client code both use
// maxBytesReader. This "requestTooLarge" check is
// only used by the server code. To prevent binaries
// which only using the HTTP Client code (such as
// cmd/go) from also linking in the HTTP server, don't
// use a static type assertion to the server
// "*response" type. Check this interface instead:
type requestTooLarger interface {
requestTooLarge()
}
if res, ok := l.w.(requestTooLarger); ok {
res.requestTooLarge()
}
l.err = &MaxBytesError{l.i}
return n, l.err
}
func (l *maxBytesReader) Close() error {
return l.r.Close()
}
func MyParseForm(r *http.Request) (vs url.Values, err error) {
if r.Body == nil {
err = errors.New("missing form body")
return
}
ct := r.Header.Get("Content-Type")
// RFC 7231, section 3.1.1.5 - empty type
// MAY be treated as application/octet-stream
if ct == "" {
ct = "application/octet-stream"
}
ct, _, err = mime.ParseMediaType(ct)
switch {
case ct == "application/x-www-form-urlencoded":
var reader io.Reader = r.Body
maxFormSize := int64(1<<63 - 1)
if _, ok := r.Body.(*maxBytesReader); !ok {
maxFormSize = int64(10 << 20) // 10 MB is a lot of text.
reader = io.LimitReader(r.Body, maxFormSize+1)
}
b, e := io.ReadAll(reader)
if e != nil {
if err == nil {
err = e
}
break
}
if int64(len(b)) > maxFormSize {
err = errors.New("http: POST too large")
return
}
vs, e = url.ParseQuery(string(b))
if err == nil {
err = e
}
case ct == "multipart/form-data":
// handled by ParseMultipartForm (which is calling us, or should be)
// TODO(bradfitz): there are too many possible
// orders to call too many functions here.
// Clean this up and write more tests.
// request_test.go contains the start of this,
// in TestParseMultipartFormOrder and others.
}
return
} }

View File

@ -4,12 +4,25 @@
<h1> <h1>
404 404
</h1> </h1>
<h2> {{ if .NotFoundMessage }}
Page Not found <h2>
</h2> {{ .NotFoundMessage }}
<div class="description"> </h2>
The page you were looking for does not exist {{ if .GoBackLink }}
</div> <div class="description">
<a hx-get="{{ .GoBackLink }}" hx-headers='{"REQUEST-TYPE": "htmlfull"}' hx-push-url="true" hx-swap="outerHTML" hx-target=".app">
👈 Go back
</a>
</div>
{{ end }}
{{ else }}
<h2>
Page Not found
</h2>
<div class="description">
The page you were looking for does not exist
</div>
{{ end }}
</div> </div>
</div> </div>
{{ end }} {{ end }}

BIN
views/imgs/upload-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -1,14 +1,49 @@
function load() { function load() {
for (const elm of document.querySelectorAll("form > button")) { for (const elm of document.querySelectorAll("form > button")) {
elm.addEventListener('click', (e) => { elm.addEventListener('click', (e) => {
e.target.parentElement.classList.add("submitted"); e.target.parentElement.classList.add("submitted");
}); });
} }
for (const elm of document.querySelectorAll("button.icon")) {
elm.addEventListener('click', (e) => {
e.preventDefault()
const input = document.querySelectorAll('form .file-upload input[type="file"]')[0];
if (input) {
input.click();
input.addEventListener('change', (e) => {
const file = input.files[0];
if (!file) return;
elm.setAttribute("disabled", "true");
const spanToReplace = document.querySelector('.file-upload .icon span');
const imgToReplace = document.querySelector('.file-upload .icon img');
if (!imgToReplace || !spanToReplace) return;
if (imgToReplace.getAttribute("replace")) {
const fileReader = new FileReader();
fileReader.onloadend = () => {
imgToReplace.setAttribute("src", fileReader.result)
elm.classList.add("adapt");
}
fileReader.readAsDataURL(file)
}
if (spanToReplace.getAttribute("replace")) {
spanToReplace.innerHTML = spanToReplace.getAttribute("replace")
}
})
}
});
}
} }
window.onload = load; window.onload = load;
htmx.on('htmx:afterSwap', load); htmx.on('htmx:afterSwap', load);
htmx.on('htmx:beforeSwap', (env) => { htmx.on('htmx:beforeSwap', (env) => {
if (env.detail.xhr.status === 401) { if (env.detail.xhr.status === 401) {
window.location = "/login" window.location = "/login"
} } else
// 309 is the code I selected for html to htmlfull change
if (env.detail.xhr.status === 309) {
env.detail.target = htmx.find(".app")
}
}); });

41
views/models/add.html Normal file
View File

@ -0,0 +1,41 @@
{{define "title"}}
Create New Model : AI Stuff
{{end}}
{{define "mainbody"}}
<main>
<h1>
Create new Model
</h1>
<form enctype="multipart/form-data" action="/models/add" method="POST" {{if .Submited}}class="submitted"{{end}} >
<fieldset>
<label for="name">Name</label>
<input id="name" name="name" required {{if .Name}} value="{{.Name}}" {{end}} />
{{if .NameFoundError}}
<span class="form-msg error">
You already have a model with that name.
</span>
{{end}}
</fieldset>
<fieldset class="file-upload" >
<label for="file">Base image</label>
<div class="form-msg">
Please provide a base image.<br/>
This image is a sample of the images that you are going to classfy.
</div>
<div class="icon-holder">
<button class="icon">
<img replace="true" src="/imgs/upload-icon.png" />
<span replace="Image selected">
Upload image
</span>
</button>
<input id="file" name="file" type="file" required accept="image/png" />
</div>
</fieldset>
<button>
Create
</button>
</form>
</main>
{{end}}

12
views/models/delete.html Normal file
View File

@ -0,0 +1,12 @@
{{ define "mainbody" }}
<main>
<h2 class="text-center">
Model {{ .Model.Name }} was deleted!
</h2>
<div class="description text-center">
<a hx-get="/models" hx-headers='{"REQUEST-TYPE": "htmlfull"}' hx-push-url="true" hx-swap="outerHTML" hx-target=".app">
👈 Go back
</a>
</div>
</main>
{{ end }}

83
views/models/edit.html Normal file
View File

@ -0,0 +1,83 @@
{{ define "title" }}
Model: {{ .Model.Name }}
{{ end }}
{{ define "base-model-card" }}
<div class="card model-card">
<h1>
{{ .Model.Name }}
</h1>
<div class="second-line">
<img src="/savedData/{{ .Model.Id }}/baseimage.png" />
<div class="info">
<div>
<span class="bold bigger">Image Type:</span> {{ .Model.Color_mode }}
</div>
<div>
<span class="bold bigger">Image Size:</span> {{ .Model.Width }}x{{ .Model.Height }}
</div>
</div>
</div>
</div>
{{ end }}
{{ define "delete-model-card" }}
<form hx-delete="/models/delete" hx-headers='{"REQUEST-TYPE": "html"}' hx-swap="outerHTML" {{ if .Error }} class="submitted" {{end}} >
<fieldset>
<span>
To delete this model please type "{{ .Model.Name }}":
</span>
<label for="name">Name</label>
<input name="name" id="name" required />
{{ if .NameDoesNotMatch }}
<span class="form-msg red">
Name does not match "{{ .Model.Name }}"
</span>
{{ end }}
</fieldset>
<input type="hidden" name="id" value="{{ .Model.Id }}" />
<button class="danger">
Delete
</button>
</form>
{{ end }}
{{ define "mainbody" }}
<main>
{{ if (eq .Model.Status 1) }}
<div>
<h1 class="text-center">
{{ .Model.Name }}
</h1>
<!-- TODO add cool animation -->
<h2 class="text-center">
Preparing the model
</h2>
</div>
{{ else if (eq .Model.Status -1) }}
<div>
<h1 class="text-center">
{{ .Model.Name }}
</h1>
<!-- TODO improve message -->
<h2 class="text-center">
Failed to prepare model
</h2>
<form hx-delete="/models/delete">
<input type="hidden" value="{{ .Model.Id }}" />
<button class="danger">
Delete
</button>
</form>
</div>
{{ else if (eq .Model.Status 2) }}
{{ template "base-model-card" . }}
{{ template "delete-model-card" . }}
{{ else }}
<h1>
Unknown Status of the model.
</h1>
{{ end }}
</main>
{{ end }}

52
views/models/list.html Normal file
View File

@ -0,0 +1,52 @@
{{define "title"}}
Models : AI Stuff
{{end}}
{{define "mainbody"}}
<main>
{{ if (lt 0 (len .List)) }}
<div class="list-header">
<h2>My Models</h2>
<div class="expand"></div>
<a class="button" hx-get="/models/add" hx-headers='{"REQUEST-TYPE": "htmlfull"}' hx-push-url="true" hx-swap="outerHTML" hx-target=".app">
New
</a>
</div>
<table>
<thead>
<tr>
<th>
Name
</th>
<th>
<!-- Open Button -->
</th>
</tr>
</thead>
<tbody>
{{range .List}}
<tr>
<td>
{{.Name}}
</td>
<td class="text-center">
<a class="button simple" hx-get="/models/edit?id={{.Id}}" hx-headers='{"REQUEST-TYPE": "htmlfull"}' hx-push-url="true" hx-swap="outerHTML" hx-target=".app">
Edit
</a>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<h2 class="text-center">
You don't have any model
</h2>
<div class="text-center">
<a class="button padded" hx-get="/models/add" hx-headers='{"REQUEST-TYPE": "htmlfull"}' hx-push-url="true" hx-swap="outerHTML" hx-target=".app">
Create a new model
</a>
</div>
{{end}}
</main>
{{end}}

View File

@ -5,6 +5,13 @@
Index Index
</a> </a>
</li> </li>
{{ if .Context.User }}
<li>
<a hx-get="/models" hx-headers='{"REQUEST-TYPE": "htmlfull"}' hx-push-url="true" hx-swap="outerHTML" hx-target=".app">
Models
</a>
</li>
{{end}}
<li class="expand"></li> <li class="expand"></li>
{{ if .Context.User }} {{ if .Context.User }}
<li> <li>

View File

@ -1,7 +1,4 @@
* { *{box-sizing: border-box;font-family: 'Roboto', sans-serif;}
box-sizing: border-box;
font-family: 'Roboto', sans-serif;
}
:root { :root {
--white: #ffffff; --white: #ffffff;
@ -18,6 +15,10 @@ body {
padding: 0; padding: 0;
} }
main {
padding: 20px 15vw;
}
.w100 { .w100 {
width: 100%; width: 100%;
display: block; display: block;
@ -32,6 +33,38 @@ body {
text-decoration: none; text-decoration: none;
} }
.bold {
font-weight: bold;
}
.bigger {
font-size: 1.1rem;
}
/* Generic */
.button,
button {
border-radius: 10px;
text-align: center;
padding: 2px;
border: none;
box-shadow: 0 2px 8px 1px #66666655;
background: var(--main);
color: var(--black);
}
.button.padded,
button.padded {
padding: 10px;
}
.button.danger,
button.danger {
background: rgb(var(--red));
color: white;
font-weight: bold;
}
/* Nav bar */ /* Nav bar */
nav { nav {
@ -49,6 +82,11 @@ nav ul {
nav ul li { nav ul li {
list-style: none; list-style: none;
padding-left: 10px;
}
nav ul li:first-child {
padding: 0;
} }
nav ul .expand { nav ul .expand {
@ -102,6 +140,7 @@ a {
form { form {
padding: 30px; padding: 30px;
margin: 20px 0;
border-radius: 10px; border-radius: 10px;
box-shadow: 2px 5px 8px 2px #66666655; box-shadow: 2px 5px 8px 2px #66666655;
} }
@ -124,6 +163,7 @@ form input:invalid:focus,
form.submitted input:invalid { form.submitted input:invalid {
box-shadow: 0 2px 5px 1px rgba(var(--red), 0.2); box-shadow: 0 2px 5px 1px rgba(var(--red), 0.2);
} }
form.submitted input:valid { form.submitted input:valid {
box-shadow: 0 2px 5px 1px rgba(var(--green), 0.2); box-shadow: 0 2px 5px 1px rgba(var(--green), 0.2);
} }
@ -146,16 +186,150 @@ form fieldset .error {
} }
form button { form button {
border-radius: 9px 10px; font-size: 1.2rem;
text-align: center;
padding: 10px;
background: none;
border: none;
box-shadow: 0 2px 8px 1px #66666655;
margin-left: 50%; margin-left: 50%;
width: 50%; width: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background: var(--main); padding: 10px;
color: var(--black); }
font-size: 1.2rem;
/* Upload files */
form fieldset.file-upload input[type="file"] {
height: 1px;
width: 1px;
padding: 0;
box-shadow: none;
}
form fieldset.file-upload .icon-holder {
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
font-size: 1rem;
}
form fieldset.file-upload .icon-holder .icon {
height: 150px;
width: 150px;
padding: 20px;
border-radius: 10px;
background: none;
transform: none;
margin: 0;
font-size: 1rem;
transition: all 1s;
}
form fieldset.file-upload .icon-holder .icon.adapt {
width: auto;
height: auto;
max-width: 80%;
max-height: 80%;
min-height: 150px;
min-width: 150px;
padding: 20px;
}
form fieldset.file-upload:has(input[type="file"]:invalid:focus) .icon,
form.submitted fieldset.file-upload:has(input[type="file"]:invalid:focus) .icon {
box-shadow: 0 2px 5px 1px rgba(var(--red), 0.2);
}
form.submitted fieldset.file-upload:has(input[type="file"]:valid:focus) .icon{
box-shadow: 0 2px 5px 1px rgba(var(--green), 0.2);
}
form fieldset.file-upload .icon-holder .icon img {
display: block;
width: 100%;
height: 80%;
object-fit: contain;
text-align: center;
transition: all 1s;
}
form fieldset.file-upload .icon-holder .icon span {
display: block;
width: 100%;
padding-top: 10px;
text-align: center;
}
/* Lists */
.list-header {
display: flex;
padding-bottom: 10px;
}
.list-header h2 {
margin: 0;
padding: 10px 5px;
}
.list-header .expand {
flex-grow: 1;
}
.list-header .button,
.list-header button {
padding: 10px 10px;
height: calc(100% - 20px);
margin-top: 5px;
}
/* Table */
table {
width: 100%;
box-shadow: 0 2px 8px 1px #66666622;
border-radius: 10px;
border-collapse: collapse;
overflow: hidden;
}
table thead {
background: #60606022;
}
table tr td,
table tr th {
border-left: 1px solid #22222244;
padding: 15px;
}
table tr td:first-child,
table tr th:first-child {
border-left: none;
}
table tr td button,
table tr td .button {
padding: 5px 10px;
box-shadow: 0 2px 5px 1px #66666655;
}
.card {
box-shadow: 0 2px 5px 1px #66666655;
padding: 20px;
border-radius: 10px;
}
/* Model stuff */
.model-card h1 {
margin: 0;
padding-bottom: 10px;
}
.model-card img {
width: 25%;
height: 100%;
object-fit: contain;
}
.model-card .second-line {
display: flex;
gap: 20px;
} }