diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..121c4f3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM golang:alpine as builder-base +LABEL builder=true multistage_tag="witb" +RUN apk add --no-cache upx ca-certificates tzdata gcc g++ + +FROM builder-base as builder-modules +LABEL builder=true multistage_tag="witb" +ARG TARGETARCH +WORKDIR /build +COPY go.mod . +COPY go.sum . +RUN go mod download +RUN go mod verify + +FROM builder-modules as builder +LABEL builder=true multistage_tag="witb" +ARG TARGETARCH +WORKDIR /build +ADD ./templates ./templates +COPY *.go . +RUN ls -l /build +RUN go env -w CGO_ENABLED=1 +#CGO_ENALBED=1 GOOS=linux GOARCH=${TARGETARCH} -ldflags '-s -w -extldflags="-static"' +RUN GOARCH=${TARGETARCH} go build -o witb +RUN upx --best --lzma witb + +FROM alpine:3.17 +WORKDIR /app + +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=builder /build/templates /app/templates +COPY --from=builder /build/witb /app/ +CMD ["/app/witb"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e97a32 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# What's in the Box (WITB) + +__What's in the Box__ is a simple storage organization solution aimed to help you identify stuff that you've stored in physical locations such as boxes or containers. + +It was made because I have not found any such solution myself to keep track of what items are stored where. + +## How does it work + +__What's in the Box__ is a web app written in Golang which communicates with a SQLite database to store boxes and the boxes' contents. You add boxes and contents to your liking. Each box also shows you a QR-Code which you can print out and attach to your boxes. Upon scanning the code, it will open the box in the browser and show you the contents. + +### What can it do + +Currently __What's in the Box__ supports the following operations: + +- Create a box with a name and a label +- Show you a QR-Code that when scanned opens the related box in the web app. +- Edit names and labels of boxes +- Delete boxes and all items associated to the box +- Search for boxes by name or label +- Add items to boxes (with quanitites) +- Edit items in boxes (Change name and quantities) +- Move items to another box +- Delete items from boxes + +### What it can't do (yet) + +As of yet, it does not have a fully fleshed-out REST API to support scipted/automated operations, although this is planned for future releases. Most of the tasks still need to be executed in the web interface. + +### Out-of-scope + +- There are no plans to add authentication to the app since it is designed to be run at home in your own network. If you want to expose it to the internet, make sure to add at least a authentication proxy in front of it or access it via a VPN. +- There is no plans for this app to handle TLS on its own. If you wanna secure the connection you must run this behind a reverse proxy where TLS will be terminated. + +### Planned + +- Full REST API to support all operations available in the web interface +- Add more attributes to items such as expiration dates for foods. +- Prometheus Metrics (Track Total Items, Total Boxes, other potential metrics) + +## Setup + +The app is designed to run inside a container although building and running it on your own is also possible. + +### Environment variables + +| Variable | Description | Default | +|---------- |--------------------------------------------------------------- |--------------- | +| PORT | Port for the web interface | 8088 | +| DB | Path to the database (will be generated if it does not exist) | /tmp/boxes.db | +| HTTP_SECURE_SCHEMA | Used to correctly set http schema [http / https] for the QR code generation | 0 | + + + +## Screenshots + +Home screen +![home](screenshots/home.png) + +Edit a box +![edit-box](screenshots/editbox.png) + +Add content to a box +![add-item](screenshots/additem.png) + +Search for boxes by name or label +![search-boxes](screenshots/search.png) + +Show qr code for box +![qr](screenshots/qr.png) \ No newline at end of file diff --git a/database.go b/database.go new file mode 100644 index 0000000..08ec825 --- /dev/null +++ b/database.go @@ -0,0 +1,358 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "time" +) + +type Database struct { + DBFilePath string +} + +// Define a custom nullable string type for JSON marshaling +type JSONNullString struct { + sql.NullString +} + +// MarshalJSON customizes JSON encoding for JSONNullString +func (ns JSONNullString) MarshalJSON() ([]byte, error) { + if ns.Valid { + return json.Marshal(ns.String) + } + return json.Marshal(nil) +} + +type Box struct { + ID int `json:"id"` + Name string `json:"name"` + Label JSONNullString `json:"label"` + CreatedAt time.Time `json:"created_at"` +} + +type BoxContent struct { + BoxID int + BoxName string + BoxLabel sql.NullString + ContentID sql.NullInt64 + Name sql.NullString + Quantity sql.NullInt64 + AddedAt sql.NullTime +} + +func (d *Database) Init() *sql.DB { + + db, err := sql.Open("sqlite3", d.DBFilePath) + if err != nil { + log.Fatal(err) + } + + createTable := ` + CREATE TABLE IF NOT EXISTS boxes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + label TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS contents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + box_id INTEGER, + name TEXT, + quantity INTEGER, + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (box_id) REFERENCES boxes(id) + );` + + if _, err := db.Exec(createTable); err != nil { + log.Fatalf("Could not create table: %v", err) + } + return db +} + +func (d *Database) GetBoxesTotal(db *sql.DB) (int, error) { + query := `SELECT COUNT(*) AS box_count FROM boxes;` + var boxCount int + + err := db.QueryRow(query).Scan(&boxCount) + if err != nil { + return 0, err + } + return boxCount, nil +} + +func (d *Database) GetBoxesPaginated(db *sql.DB, page int, pageSize int) ([]Box, error) { + offset := (page * pageSize) / pageSize + + query := `SELECT id, name, label, created_at FROM boxes ORDER BY created_at DESC LIMIT ? OFFSET ?` + + rows, err := db.Query(query, pageSize, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var boxes []Box + for rows.Next() { + var box Box + if err := rows.Scan(&box.ID, &box.Name, &box.Label, &box.CreatedAt); err != nil { + return nil, err + } + boxes = append(boxes, box) + } + + if err := rows.Err(); err != nil { + return nil, err + } + return boxes, nil +} + +func (d *Database) GetBoxesByTextV0(db *sql.DB, searchText string) ([]Box, error) { + query := ` + SELECT id, name, label, created_at + FROM boxes + WHERE name LIKE '%' || ? || '%' + OR label LIKE '%' || ? || '%';` + + rows, err := db.Query(query, searchText, searchText) + if err != nil { + return nil, err + } + defer rows.Close() + + var boxes []Box + for rows.Next() { + var box Box + if err := rows.Scan(&box.ID, &box.Name, &box.Label, &box.CreatedAt); err != nil { + return nil, err + } + boxes = append(boxes, box) + } + + if err := rows.Err(); err != nil { + return nil, err + } + return boxes, nil +} + +func (d *Database) GetBoxes(db *sql.DB) ([]Box, error) { + query := `SELECT id, label, name, created_at FROM boxes` + rows, err := db.Query(query) + if err != nil { + log.Fatal(err) + } + defer rows.Close() + + var boxes []Box + for rows.Next() { + var box Box + if err := rows.Scan(&box.ID, &box.Label, &box.Name, &box.CreatedAt); err != nil { + return nil, err + } + if !box.Label.Valid { + box.Label.String = "unlabeled" + } + boxes = append(boxes, box) + } + if err := rows.Err(); err != nil { + return nil, err + } + return boxes, nil +} + +func (d *Database) GetBoxContent(db *sql.DB, boxID int) ([]BoxContent, error) { + query := ` + SELECT + boxes.id AS box_id, + boxes.name AS box_name, + boxes.label AS box_label, + contents.id AS content_id, + contents.name AS content_name, + contents.quantity AS content_quantity, + contents.added_at AS content_added_at + FROM + boxes + LEFT JOIN + contents ON boxes.id = contents.box_id + WHERE + boxes.id = ? + ORDER BY contents.added_at DESC` + + rows, err := db.Query(query, boxID) + if err != nil { + log.Fatalln(err) + } + defer rows.Close() + + var boxContents []BoxContent + for rows.Next() { + var content BoxContent + if err := rows.Scan(&content.BoxID, &content.BoxName, &content.BoxLabel, &content.ContentID, &content.Name, &content.Quantity, &content.AddedAt); err != nil { + return nil, err + } + if !content.BoxLabel.Valid { + content.BoxLabel.String = "unlabeled" + } + boxContents = append(boxContents, content) + } + if err := rows.Err(); err != nil { + return nil, err + } + return boxContents, nil +} + +func (d *Database) UpdateBoxContent(db *sql.DB, contentID int, newName string, newQuantity int) error { + query := `UPDATE contents SET name = ?, quantity = ? WHERE id = ?` + result, err := db.Exec(query, newName, newQuantity, contentID) + if err != nil { + return err + } + rowsAffected, err := result.RowsAffected() + + if err != nil { + return err + } + if rowsAffected == 0 { + return fmt.Errorf("no content found with id %d", contentID) + } + return nil +} + +func (d *Database) CreateBox(db *sql.DB, name string, label string) error { + query := `INSERT INTO boxes (name, label) VALUES (?, ?)` + result, err := db.Exec(query, name, label) + if err != nil { + return err + } + boxId, err := result.LastInsertId() + if err != nil { + return err + } + log.Println(boxId) + return nil +} + +func (d *Database) DeleteBox(db *sql.DB, id int) error { + // Start a transaction + tx, err := db.Begin() + if err != nil { + return err + } + // Defer a rollback in case something fails. + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // First, delete all contents associated with the box + deleteContentsQuery := `DELETE FROM contents WHERE box_id = ?` + _, err = tx.Exec(deleteContentsQuery, id) + if err != nil { + return fmt.Errorf("failed to delete contents: %w", err) + } + + // Then, delete the box itself + deleteBoxQuery := `DELETE FROM boxes WHERE id = ?` + _, err = tx.Exec(deleteBoxQuery, id) + if err != nil { + return fmt.Errorf("failed to delete box: %w", err) + } + + // Commit the transaction + err = tx.Commit() + if err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + return nil +} + +func (d *Database) UpdateBox(db *sql.DB, id int, newName string, newLabel string) error { + query := `UPDATE boxes SET name = ?, label = ? WHERE id = ?` + result, err := db.Exec(query, newName, newLabel, id) + if err != nil { + return err + } + rowsAffected, err := result.RowsAffected() + + if err != nil { + return err + } + if rowsAffected == 0 { + return fmt.Errorf("no box found with id %d", id) + } + return nil +} + +func (d *Database) CreateItem(db *sql.DB, boxId int, name string, quantity int) error { + query := `INSERT INTO contents (name, quantity, box_id) VALUES (?, ?, ?)` + result, err := db.Exec(query, name, quantity, boxId) + if err != nil { + return err + } + contentId, err := result.LastInsertId() + if err != nil { + return err + } + log.Println(contentId) + return nil +} + +func (d *Database) MoveItem(db *sql.DB, sourceBoxID, destBoxID, contentId int) error { + query := ` + UPDATE contents + SET box_id = ? + WHERE box_id = ? AND id = ?` + + // Start a transaction + tx, err := db.Begin() + if err != nil { + return err + } + // Defer a rollback in case something fails. + defer func() { + if err != nil { + tx.Rollback() + } + }() + + _, err = tx.Exec(query, destBoxID, sourceBoxID, contentId) + if err != nil { + return err + } + // Commit the transaction + err = tx.Commit() + if err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + return nil +} + +func (d *Database) DeleteItem(db *sql.DB, id int) error { + // Start a transaction + tx, err := db.Begin() + if err != nil { + return err + } + // Defer a rollback in case something fails. + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // Delete item from box + deleteContentsQuery := `DELETE FROM contents WHERE id = ?` + _, err = tx.Exec(deleteContentsQuery, id) + if err != nil { + return fmt.Errorf("failed to delete contents from box: %w", err) + } + + // Commit the transaction + err = tx.Commit() + if err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..04e9f16 --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module witb + +go 1.23.2 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/mattn/go-sqlite3 v1.14.24 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1f34e39 --- /dev/null +++ b/go.sum @@ -0,0 +1,83 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..e06b6b5 --- /dev/null +++ b/main.go @@ -0,0 +1,359 @@ +package main + +import ( + "database/sql" + "encoding/base64" + "fmt" + "html/template" + "log" + "math" + "net/http" + "os" + "strconv" + + "time" + + "github.com/gin-gonic/gin" + _ "github.com/mattn/go-sqlite3" + "github.com/skip2/go-qrcode" +) + +var ( + version = "v0.5" + database Database + client *sql.DB + secure bool +) + +// Initialize Database and return sql connection to client +func init() { + database = Database{ + DBFilePath: getEnv("DB", "/tmp/boxes.db"), + } + client = database.Init() + var err error + secure, err = strconv.ParseBool(getEnv("HTTP_SECURE_SCHEMA", "0")) + if err != nil { + secure = false + } +} + +// Template function to pretty print time data types as string +func formatAsDate(t time.Time) string { + year, month, day := t.Date() + return fmt.Sprintf("%d/%02d/%02d", year, month, day) +} + +// Handle setting of variables of env var is not set +func getEnv(key, defaultValue string) string { + value := os.Getenv(key) + if len(value) == 0 { + return defaultValue + } + return value +} + +// Get all boxes and return html page +func getBox(c *gin.Context) { + + pageStr := c.DefaultQuery("page", "1") + page, err := strconv.Atoi(pageStr) + if err != nil || page < 1 { + page = 1 + } + // Define items per page + const itemsPerPage = 5 + offset := (page - 1) * itemsPerPage + totalItems, err := database.GetBoxesTotal(client) + boxes, err := database.GetBoxesPaginated(client, offset, itemsPerPage) + totalPages := int(math.Ceil(float64(totalItems) / float64(itemsPerPage))) + + if err != nil { + log.Println(err) + c.JSON(http.StatusInternalServerError, gin.H{"fail": "could not get boxes"}) + return + } + c.HTML(http.StatusOK, "boxes.tmpl", gin.H{ + "boxes": boxes, + "version": version, + "CurrentPage": page, + "TotalPages": totalPages, + }) +} + +// Get all box contents for a certain box and return html page +func getBoxContent(c *gin.Context) { + idParam := c.Params.ByName("id") + id, err := strconv.Atoi(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID for getting box content"}) + return + } + contents, err := database.GetBoxContent(client, id) + if err != nil { + log.Println(err) + c.JSON(http.StatusInternalServerError, gin.H{"fail": "could not get box contents"}) + return + } + + // User opened an invalid box. + if len(contents) == 0 { + c.JSON(http.StatusNotFound, gin.H{"fail": "box does not exist"}) + return + } + + var png []byte + currentURL := c.Request.Host + c.Request.RequestURI + schema := "http://" + if secure { + schema = "https://" + } + log.Println(schema) + log.Println(currentURL) + fullURL := schema + currentURL // Or "https://" if using HTTPS + png, err = qrcode.Encode(fullURL, qrcode.Medium, 156) // 256x256 image + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate QR code"}) + return + } + qrCodeBase64 := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png) + qrCodeSafeURL := template.HTML(`QR Code`) + c.HTML(http.StatusOK, "content.tmpl", gin.H{ + "QRCode": qrCodeSafeURL, + "contents": contents, + }) +} + +func updateBoxContent(c *gin.Context) { + boxidParam := c.Params.ByName("boxid") + boxid, err := strconv.Atoi(boxidParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Box ID"}) + return + } + + idParam := c.Params.ByName("id") + id, err := strconv.Atoi(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID for box content"}) + return + } + c.Request.ParseForm() + name := c.PostForm("item_name") + quantityString := c.PostForm("item_amount") + + quantity, err := strconv.Atoi(quantityString) + if err != nil { + log.Println(err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Quantity"}) + return + } + + err = database.UpdateBoxContent(client, id, name, quantity) + if err != nil { + log.Println(err) + c.JSON(http.StatusInternalServerError, gin.H{"fail": "could not get boxes contents"}) + return + } + c.Redirect(http.StatusFound, fmt.Sprintf("/box/%d", boxid)) +} + +func createBox(c *gin.Context) { + c.Request.ParseForm() + name := c.PostForm("item_name") + label := c.PostForm("item_label") + err := database.CreateBox(client, name, label) + if err != nil { + log.Println(err) + c.JSON(http.StatusInternalServerError, gin.H{"fail": "could not create new box"}) + return + } + query, exists := c.GetQuery("page") + if !exists { + c.Redirect(http.StatusFound, fmt.Sprintf("/")) + } else { + c.Redirect(http.StatusFound, fmt.Sprintf("/?page=%s", query)) + } + +} + +func deleteBox(c *gin.Context) { + type DeleteRequest struct { + ID string `json:"id" binding:"required"` + } + var req DeleteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + boxid, err := strconv.Atoi(req.ID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Box ID"}) + return + } + err = database.DeleteBox(client, boxid) + if err != nil { + log.Println(err) + c.JSON(http.StatusInternalServerError, gin.H{"fail": "could not delete box"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "box deleted", + "id": req.ID, + }) +} + +func updateBox(c *gin.Context) { + idParam := c.Params.ByName("boxid") + id, err := strconv.Atoi(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID for box"}) + return + } + c.Request.ParseForm() + name := c.PostForm("item_name") + label := c.PostForm("item_label") + + err = database.UpdateBox(client, id, name, label) + if err != nil { + log.Println(err) + c.JSON(http.StatusInternalServerError, gin.H{"fail": "could not edit box"}) + return + } + c.Redirect(http.StatusFound, "/") +} + +func createItem(c *gin.Context) { + boxidParam := c.Params.ByName("boxid") + boxid, err := strconv.Atoi(boxidParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Box ID for new item"}) + return + } + c.Request.ParseForm() + name := c.PostForm("item_name") + quantityString := c.PostForm("item_amount") + quantity, err := strconv.Atoi(quantityString) + if err != nil { + log.Println(err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Quantity for item"}) + return + } + err = database.CreateItem(client, boxid, name, quantity) + if err != nil { + log.Println(err) + c.JSON(http.StatusInternalServerError, gin.H{"fail": "could not create new item in box"}) + return + } + c.Redirect(http.StatusFound, fmt.Sprintf("/box/%d", boxid)) +} + +func deleteItem(c *gin.Context) { + type DeleteRequest struct { + ID string `json:"id" binding:"required"` + } + var req DeleteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + itemId, err := strconv.Atoi(req.ID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Item ID"}) + return + } + err = database.DeleteItem(client, itemId) + if err != nil { + log.Println(err) + c.JSON(http.StatusInternalServerError, gin.H{"fail": "could not delete item"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "item deleted", + "id": req.ID, + }) +} + +func apiGetBox(c *gin.Context) { + query := c.DefaultQuery("search", "") + boxes, err := database.GetBoxesByTextV0(client, query) + if err != nil { + log.Println(err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "could not query boxes. Internal Server Error.", + }) + return + } + if len(boxes) == 0 { + boxes = make([]Box, 0) + } + c.JSON(http.StatusOK, gin.H{ + "message": "success", + "count": len(boxes), + "result": boxes, + }) + +} + +func moveItem(c *gin.Context) { + type MoveRequest struct { + TargetBox int `json:"targetBox" binding:"required"` + SourceBox int `json:"sourceBox" binding:"required"` + SourceItem int `json:"sourceItem" binding:"required"` + } + var req MoveRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + err := database.MoveItem(client, req.SourceBox, req.TargetBox, req.SourceItem) + if err != nil { + log.Println(err) + c.JSON(http.StatusInternalServerError, gin.H{"fail": "could not move item"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "item moved", + "id": req.SourceItem, + "oldBoxId": req.SourceBox, + "newBoxId": req.TargetBox, + }) +} + +func main() { + + router := gin.Default() + // Register helper functions for pagination + router.SetFuncMap(template.FuncMap{ + "formatAsDate": formatAsDate, + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + "seq": func(start int, end int) []int { + s := make([]int, end-start+1) + for i := range s { + s[i] = start + i + } + return s + }, + }) + + router.LoadHTMLGlob("templates/*") + router.GET("/", getBox) + + // Group all Box endpoints together + box := router.Group("/box") + box.DELETE("/delete", deleteBox) + box.POST("/create", createBox) + box.POST("/:boxid/edit/:id", updateBoxContent) + box.POST("/:boxid/edit", updateBox) + box.POST("/:boxid/create", createItem) + box.GET("/:id", getBoxContent) + + router.DELETE("/item", deleteItem) + + apiV0 := router.Group("/api/v0") + apiV0.GET("/box", apiGetBox) + apiV0.PATCH("/item/move", moveItem) + + router.Run(fmt.Sprintf("0.0.0.0:%s", getEnv("PORT", "8088"))) +} diff --git a/screenshots/additem.png b/screenshots/additem.png new file mode 100644 index 0000000..1c3eff4 Binary files /dev/null and b/screenshots/additem.png differ diff --git a/screenshots/editbox.png b/screenshots/editbox.png new file mode 100644 index 0000000..746621f Binary files /dev/null and b/screenshots/editbox.png differ diff --git a/screenshots/home.png b/screenshots/home.png new file mode 100644 index 0000000..431b991 Binary files /dev/null and b/screenshots/home.png differ diff --git a/screenshots/qr.png b/screenshots/qr.png new file mode 100644 index 0000000..628e3c8 Binary files /dev/null and b/screenshots/qr.png differ diff --git a/screenshots/search.png b/screenshots/search.png new file mode 100644 index 0000000..68589af Binary files /dev/null and b/screenshots/search.png differ diff --git a/templates/boxes.tmpl b/templates/boxes.tmpl new file mode 100644 index 0000000..1b11355 --- /dev/null +++ b/templates/boxes.tmpl @@ -0,0 +1,252 @@ +{{template "header".}} +
+

What's in the Box

+

Keep track of your storage box contents

+ {{ .version }} +
+
+
+
+
  • +

    Boxes

    +
    +
    + + +
    +
    +
  • +
    +
    + +
    + + + + + + + +{{template "footer"}} diff --git a/templates/content.tmpl b/templates/content.tmpl new file mode 100644 index 0000000..e5acec9 --- /dev/null +++ b/templates/content.tmpl @@ -0,0 +1,300 @@ +{{template "header" . }} + +
    +

    {{ (index .contents 0).BoxName }}

    +

    + {{ (index .contents 0).BoxLabel.String }} +

    + + +
    + {{ .QRCode }} +
    +
    + Note: When scanned, this QR-Code will open this page. Print it out and attach it to your box. +
    +
    +
    +
    +
    +
    +
    +
  • +

    Contents

    + +
  • +
    +
    + +
    + + + + + + +{{template "footer"}} diff --git a/templates/footer.tmpl b/templates/footer.tmpl new file mode 100644 index 0000000..5984fec --- /dev/null +++ b/templates/footer.tmpl @@ -0,0 +1,9 @@ +{{define "footer"}} + + + + +{{end}} diff --git a/templates/header.tmpl b/templates/header.tmpl new file mode 100644 index 0000000..8f8afab --- /dev/null +++ b/templates/header.tmpl @@ -0,0 +1,23 @@ +{{define "header" }} + + + + + + + + + + + + +{{end}} \ No newline at end of file