Compare commits

..

22 Commits

Author SHA1 Message Date
Steve White 8a620576b9 resolved issues with tag system and API 2024-10-31 11:57:01 -05:00
Steve White 4d9d722940 trying to fix routes 2024-10-29 19:40:42 -05:00
Steve White c604251b7a more logging fixes 2024-10-29 11:32:47 -05:00
Steve White cb202de53f refactored for ordered consistent logging 2024-10-29 10:09:14 -05:00
Steve White d0eb8f733c Refactoring handlers into appropriate files. 2024-10-26 23:09:45 -05:00
Steve White 06755693a1 fixed logging. Pointlessly. 2024-10-26 17:56:59 -05:00
Steve White 4b95b299f1 fixing variable contretemps 2024-10-26 12:22:57 -05:00
Steve White 54b412a3fe refactored by AI to be clearer and easier to support. Seems to work. 2024-10-25 23:50:04 -05:00
Steve White 76a03cda57 patching up to run on podman on my local mac. 2024-10-24 09:17:36 -05:00
Steve White 96d2aeae19 Added docker-compose file to start service 2024-10-23 12:51:43 -05:00
Steve White 188f09e5fe Fixed so that go serves static REACT files as well 2024-10-23 12:47:16 -05:00
Steve White 998ae4295b changed api endpoints to include /api/v1 for proxy magic 2024-10-21 11:28:30 -05:00
Steve White 4b2b8e96c1 add /api/v1 to endpoint path for proxying. 2024-10-21 11:06:32 -05:00
Steve White 0f33941e65 nade CORS allowed origins a variable 2024-10-18 10:00:04 -05:00
Steve White ccb53c85c6 enabled sqlite3 auto_vacuum 2024-10-17 10:07:12 -05:00
Steve White 0f2b213406 Encrypt passwords in the database 2024-10-17 10:03:07 -05:00
Steve White 2689d2296c removed the "Getting Image" log message 2024-10-17 09:41:37 -05:00
Steve White 75c8dde932 added email to user database 2024-10-17 09:38:52 -05:00
Steve White 6660c1e3b3 user and database administration 2024-10-17 00:08:28 -05:00
Steve White 9bdcc1f7db added db restore 2024-10-16 17:28:00 -05:00
Steve White 2d63c02048 added admin functions for user management and working on database management 2024-10-16 17:10:20 -05:00
Steve White 3cb082b3b0 Updating readme 2024-10-15 17:37:16 -05:00
35 changed files with 2552 additions and 556 deletions

2
.gitignore vendored
View File

@ -2,3 +2,5 @@
data/* data/*
images/* images/*
.DS_Store .DS_Store
build/*
boxes-api

View File

@ -4,6 +4,7 @@
This project is a backend API built using Go, designed for managing boxes and the items stored in those boxes. The application is containerized using Docker and uses SQLite3 for data storage. It also supports JWT-based authentication and logs various activities such as logins and box/item operations. This project is a backend API built using Go, designed for managing boxes and the items stored in those boxes. The application is containerized using Docker and uses SQLite3 for data storage. It also supports JWT-based authentication and logs various activities such as logins and box/item operations.
## Features ## Features
- Manage boxes and items stored within them. - Manage boxes and items stored within them.
- JWT-based authentication. - JWT-based authentication.
- Automatic database creation if it doesn't exist. - Automatic database creation if it doesn't exist.
@ -13,6 +14,7 @@ This project is a backend API built using Go, designed for managing boxes and th
- Containerized using Docker. - Containerized using Docker.
## Tech Stack ## Tech Stack
- **Language:** Go - **Language:** Go
- **Database:** SQLite3 - **Database:** SQLite3
- **Authentication:** JWT - **Authentication:** JWT
@ -21,10 +23,12 @@ This project is a backend API built using Go, designed for managing boxes and th
## API Endpoints ## API Endpoints
### Authentication ### Authentication
- **POST** `/login` - **POST** `/login`
Authenticates a user and returns a JWT. Authenticates a user and returns a JWT.
### Boxes ### Boxes
- **GET** `/boxes` - **GET** `/boxes`
Retrieves all boxes. Retrieves all boxes.
- **POST** `/boxes` - **POST** `/boxes`
@ -35,6 +39,7 @@ This project is a backend API built using Go, designed for managing boxes and th
Retrieves all items in a box by its ID. Retrieves all items in a box by its ID.
### Items ### Items
- **GET** `/items` - **GET** `/items`
Retrieves all items, optionally searchable by description. Retrieves all items, optionally searchable by description.
- **POST** `/items` - **POST** `/items`
@ -60,11 +65,11 @@ jwt_secret: "your_jwt_secret"
log_file: "./logs/app.log" log_file: "./logs/app.log"
image_storage_directory: "./images" image_storage_directory: "./images"
``` ```
The server also expects the CONFIG environment variable to be set. e.g. `export CONFIG=config/config.yaml`.
## Setup and Running ## Setup and Running
### Prerequisites ### Prerequisites
- Docker - Docker
- Go (if running locally) - Go (if running locally)
- SQLite3 - SQLite3
@ -72,11 +77,13 @@ The server also expects the CONFIG environment variable to be set. e.g. `export
### Running with Docker ### Running with Docker
1. Build the Docker image: 1. Build the Docker image:
```bash ```bash
docker build -t box-management-api . docker build -t box-management-api .
``` ```
2. Run the Docker container: 2. Run the Docker container:
```bash ```bash
docker run -p 8080:8080 box-management-api docker run -p 8080:8080 box-management-api
``` ```
@ -84,17 +91,20 @@ The server also expects the CONFIG environment variable to be set. e.g. `export
### Running Locally ### Running Locally
1. Clone the repository: 1. Clone the repository:
```bash ```bash
git clone https://github.com/your-repo/box-management-api.git git clone https://github.com/your-repo/box-management-api.git
cd box-management-api cd box-management-api
``` ```
2. Build the Go application: 2. Build the Go application:
```bash ```bash
go build -o main . go build -o main .
``` ```
3. Run the application: 3. Run the application:
```bash ```bash
./main ./main
``` ```
@ -107,7 +117,7 @@ The application creates the following SQLite3 tables:
- `items`: Contains `id` (int), `name` (text), `description` (text), `box_id` (int), and `image_path` (text). - `items`: Contains `id` (int), `name` (text), `description` (text), `box_id` (int), and `image_path` (text).
- `users`: Contains `id` (int), `username` (text), and `password` (hashed). - `users`: Contains `id` (int), `username` (text), and `password` (hashed).
## Authentication ## Authentication API
The API uses JWT-based authentication. A default user is created on startup: The API uses JWT-based authentication. A default user is created on startup:
@ -117,9 +127,11 @@ The API uses JWT-based authentication. A default user is created on startup:
## Logs ## Logs
The following events are logged to the file specified in the configuration: The following events are logged to the file specified in the configuration:
- User logins - User logins
- Box creation/deletion - Box creation/deletion
- Item creation/deletion - Item creation/deletion
## License ## License
This project is licensed under the MIT License. This project is licensed under the MIT License.

294
admin.go Normal file
View File

@ -0,0 +1,294 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"github.com/gorilla/mux"
"golang.org/x/crypto/bcrypt"
)
// GetUsersHandler handles GET requests to /admin/user
func GetUsersHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
username := r.Context().Value(userKey).(string)
log.Info("Admin request to get all users by user: %s", username)
var users []User
if err := db.Find(&users).Error; err != nil {
log.Error("Failed to fetch users: %v", err)
http.Error(w, "Failed to fetch users", http.StatusInternalServerError)
return
}
log.Info("Successfully retrieved %d users", len(users))
json.NewEncoder(w).Encode(users)
}
// CreateUserHandler handles POST requests to /admin/user
func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
adminUser := r.Context().Value(userKey).(string)
var user User
err := json.NewDecoder(r.Body).Decode(&user)
if err != nil {
log.Error("Failed to decode user creation request: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
log.Info("Admin user %s attempting to create new user: %s", adminUser, user.Username)
// Hash the password before storing
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), 12)
if err != nil {
log.Error("Failed to hash password for new user %s: %v", user.Username, err)
http.Error(w, "Failed to hash password", http.StatusInternalServerError)
return
}
user.Password = string(hashedPassword)
if err := db.Create(&user).Error; err != nil {
log.Error("Failed to create user %s: %v", user.Username, err)
http.Error(w, "Failed to create user", http.StatusInternalServerError)
return
}
log.DatabaseAction("create", fmt.Sprintf("User %s created by admin %s", user.Username, adminUser))
json.NewEncoder(w).Encode(user)
}
// GetUserHandler handles GET requests to /admin/user/{id}
func GetUserHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
adminUser := r.Context().Value(userKey).(string)
vars := mux.Vars(r)
id := vars["id"]
log.Info("Admin user %s requesting details for user ID: %s", adminUser, id)
var user User
if err := db.First(&user, id).Error; err != nil {
log.Warn("User not found with ID %s, requested by admin %s", id, adminUser)
http.Error(w, "User not found", http.StatusNotFound)
return
}
log.Info("Successfully retrieved user %s details", user.Username)
json.NewEncoder(w).Encode(user)
}
// DeleteUserHandler handles DELETE requests to /admin/user/{id}
func DeleteUserHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
adminUser := r.Context().Value(userKey).(string)
vars := mux.Vars(r)
id := vars["id"]
log.Info("Admin user %s attempting to delete user ID: %s", adminUser, id)
var user User
if err := db.First(&user, id).Error; err != nil {
log.Warn("Attempt to delete non-existent user ID %s by admin %s", id, adminUser)
http.Error(w, "User not found", http.StatusNotFound)
return
}
if err := db.Delete(&user).Error; err != nil {
log.Error("Failed to delete user %s: %v", user.Username, err)
http.Error(w, "Failed to delete user", http.StatusInternalServerError)
return
}
log.DatabaseAction("delete", fmt.Sprintf("User %s deleted by admin %s", user.Username, adminUser))
w.WriteHeader(http.StatusNoContent)
}
// BackupDatabaseHandler handles GET requests to /admin/db
func BackupDatabaseHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
adminUser := r.Context().Value(userKey).(string)
log.Info("Database backup requested by admin user: %s", adminUser)
// Open the database file using the path from the config
file, err := os.Open(*DatabasePath)
if err != nil {
log.Error("Failed to open database file for backup: %v", err)
http.Error(w, "Failed to open database file", http.StatusInternalServerError)
return
}
defer file.Close()
// Copy the file to the response writer
_, err = io.Copy(w, file)
if err != nil {
log.Error("Failed to send database backup: %v", err)
http.Error(w, "Failed to send database file", http.StatusInternalServerError)
return
}
log.DatabaseAction("backup", fmt.Sprintf("Database backup created by admin %s", adminUser))
log.Info("Database backup successfully completed")
}
// RestoreDatabaseHandler handles POST requests to /admin/db
func RestoreDatabaseHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
adminUser := r.Context().Value(userKey).(string)
log.Info("Database restore requested by admin user: %s", adminUser)
// Create a backup of the existing database
err := createDatabaseBackup()
if err != nil {
log.Error("Failed to create backup before restore: %v", err)
http.Error(w, "Failed to create database backup", http.StatusInternalServerError)
return
}
log.Info("Created backup of existing database")
// Save the new database
err = saveNewDatabase(r)
if err != nil {
log.Error("Failed to save new database during restore: %v", err)
http.Error(w, "Failed to save new database", http.StatusInternalServerError)
return
}
log.Info("Saved new database file")
// Validate the new database is properly initialized
err = validateNewDatabase()
if err != nil {
log.Error("New database validation failed: %v", err)
http.Error(w, "New database is not properly initialized", http.StatusInternalServerError)
return
}
log.Info("New database validation successful")
// Switch to the new database app-wide
err = switchToNewDatabase()
if err != nil {
log.Error("Failed to switch to new database: %v", err)
http.Error(w, "Failed to switch to new database", http.StatusInternalServerError)
return
}
log.DatabaseAction("restore", fmt.Sprintf("Database restored by admin %s", adminUser))
log.Info("Database restore completed successfully")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Database restored successfully"})
}
func createDatabaseBackup() error {
log := GetLogger()
backupPath := *DatabasePath + ".bak"
log.Info("Creating database backup at: %s", backupPath)
src, err := os.Open(*DatabasePath)
if err != nil {
log.Error("Failed to open source database for backup: %v", err)
return err
}
defer src.Close()
dst, err := os.Create(backupPath)
if err != nil {
log.Error("Failed to create backup file: %v", err)
return err
}
defer dst.Close()
_, err = io.Copy(dst, src)
if err != nil {
log.Error("Failed to copy database to backup: %v", err)
return err
}
log.Info("Database backup created successfully")
return nil
}
func saveNewDatabase(r *http.Request) error {
log := GetLogger()
file, _, err := r.FormFile("database")
if err != nil {
log.Error("Failed to get database file from request: %v", err)
return err
}
defer file.Close()
dst, err := os.Create(*DatabasePath)
if err != nil {
log.Error("Failed to create new database file: %v", err)
return err
}
defer dst.Close()
_, err = io.Copy(dst, file)
if err != nil {
log.Error("Failed to save new database file: %v", err)
return err
}
log.Info("New database file saved successfully")
return nil
}
func validateNewDatabase() error {
log := GetLogger()
log.Info("Validating new database")
db, err := ConnectDB(*DatabasePath)
if err != nil {
log.Error("Failed to connect to new database: %v", err)
return err
}
defer db.Close()
// Check if required tables exist
tables := []string{"users", "boxes", "items"}
for _, table := range tables {
var count int
err := db.Debug().Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?;", table).Row().Scan(&count)
if err != nil {
log.Error("Error checking for table %s: %v", table, err)
return err
}
if count == 0 {
log.Error("Required table %s does not exist in new database", table)
return fmt.Errorf("table %s does not exist", table)
}
log.Info("Validated table exists: %s", table)
}
log.Info("Database validation completed successfully")
return nil
}
func switchToNewDatabase() error {
log := GetLogger()
log.Info("Switching to new database")
newDB, err := ConnectDB(*DatabasePath)
if err != nil {
log.Error("Failed to connect to new database during switch: %v", err)
return err
}
// Update the global db variable
db = newDB
log.Info("Successfully switched to new database")
return nil
}

View File

@ -1,128 +1,490 @@
# Boxes API - Frontend Specification # Boxes API Specification
This document outlines the API endpoints for a simple inventory management system called "Boxes". ## Base URL
`/api/v1`
**Authentication:** ## Authentication
- All endpoints except `/login` require JWT authentication
- JWT token must be provided in the Authorization header as `Bearer <token>`
- Tokens expire after 24 hours
* All endpoints (except `/login`) require a valid JWT token in the `Authorization` header, formatted as `Bearer <token>`. ## Endpoints
* To obtain a token, send a POST request to `/login` with the following JSON payload:
### Authentication
#### Login
- **URL**: `/login`
- **Method**: `POST`
- **Auth Required**: No
- **Request Body**:
```json
{
"username": "string",
"password": "string"
}
```
- **Success Response**:
- **Code**: 200
- **Content**:
```json ```json
{ {
"username": "your_username", "token": "string"
"password": "your_password"
} }
``` ```
- **Error Responses**:
- **Code**: 401 UNAUTHORIZED
- **Content**: "Invalid username or password"
* Successful login will return a JSON response with the token: ### Boxes
```json
{
"token": "your_jwt_token"
}
```
**Endpoints:**
**1. Boxes:**
* **GET /boxes:**
* Returns a list of all boxes.
* Response: Array of Box objects
#### Get All Boxes
- **URL**: `/boxes`
- **Method**: `GET`
- **Auth Required**: Yes
- **Success Response**:
- **Code**: 200
- **Content**:
```json ```json
[ [
{ {
"id": 1, "ID": "number",
"name": "Kitchen" "CreatedAt": "timestamp",
}, "UpdatedAt": "timestamp",
{ "DeletedAt": "timestamp|null",
"id": 2, "name": "string"
"name": "Bedroom"
} }
] ]
``` ```
* **POST /boxes:** #### Create Box
* Creates a new box. - **URL**: `/boxes`
* Request body: JSON object with the box name - **Method**: `POST`
- **Auth Required**: Yes
- **Request Body**:
```json
{
"name": "string"
}
```
- **Success Response**:
- **Code**: 200
- **Content**:
```json ```json
{ {
"name": "New Box" "id": "number",
"name": "string"
} }
``` ```
* Response: JSON object with the created box's ID and name #### Get Box
- **URL**: `/boxes/{id}`
- **Method**: `GET`
- **Auth Required**: Yes
- **Success Response**:
- **Code**: 200
- **Content**:
```json ```json
{ {
"id": 3, "ID": "number",
"name": "New Box" "CreatedAt": "timestamp",
"UpdatedAt": "timestamp",
"DeletedAt": "timestamp|null",
"name": "string"
} }
``` ```
- **Error Response**:
- **Code**: 404 NOT FOUND
- **Content**: "Box not found"
* **DELETE /boxes/{id}:** #### Delete Box
* Deletes the box with the specified ID. - **URL**: `/boxes/{id}`
* Response: 204 No Content - **Method**: `DELETE`
- **Auth Required**: Yes
- **Success Response**:
- **Code**: 204 NO CONTENT
- **Error Response**:
- **Code**: 404 NOT FOUND
- **Content**: "Box not found"
**2. Items:** ### Items
* **GET /items:**
* Returns a list of all items.
* Response: Array of Item objects
#### Get All Items
- **URL**: `/items`
- **Method**: `GET`
- **Auth Required**: Yes
- **Success Response**:
- **Code**: 200
- **Content**:
```json ```json
[ [
{ {
"id": 1, "ID": "number",
"name": "Fork", "CreatedAt": "timestamp",
"description": "Silverware", "UpdatedAt": "timestamp",
"box_id": 1, "DeletedAt": "timestamp|null",
"image_path": "path/to/image.jpg" "name": "string",
}, "description": "string",
{ "box_id": "number",
"id": 2, "image_path": "string",
"name": "Pillow", "tags": [
"description": "Fluffy", {
"box_id": 2, "ID": "number",
"image_path": "path/to/another_image.png" "name": "string",
"description": "string",
"color": "string"
}
]
} }
] ]
``` ```
* **POST /items:** #### Create Item
* Creates a new item. - **URL**: `/items`
* Request body: JSON object with item details - **Method**: `POST`
- **Auth Required**: Yes
- **Request Body**:
```json
{
"name": "string",
"description": "string",
"box_id": "number"
}
```
- **Success Response**:
- **Code**: 200
- **Content**:
```json ```json
{ {
"name": "Spoon", "id": "number",
"description": "For soup", "name": "string"
"box_id": 1,
"image_path": "path/to/spoon_image.jpg"
} }
``` ```
* Response: JSON object with the created item's ID and name #### Get Item
- **URL**: `/items/{id}`
- **Method**: `GET`
- **Auth Required**: Yes
- **Success Response**:
- **Code**: 200
- **Content**:
```json ```json
{ {
"id": 3, "ID": "number",
"name": "Spoon" "CreatedAt": "timestamp",
"UpdatedAt": "timestamp",
"DeletedAt": "timestamp|null",
"name": "string",
"description": "string",
"box_id": "number",
"image_path": "string",
"tags": [
{
"ID": "number",
"name": "string",
"description": "string",
"color": "string"
}
]
}
```
- **Error Response**:
- **Code**: 404 NOT FOUND
- **Content**: "Item not found"
#### Update Item
- **URL**: `/items/{id}`
- **Method**: `PUT`
- **Auth Required**: Yes
- **Request Body**:
```json
{
"name": "string",
"description": "string",
"box_id": "number"
}
```
- **Success Response**:
- **Code**: 200
- **Content**: Updated item object
- **Error Response**:
- **Code**: 404 NOT FOUND
- **Content**: "Item not found"
#### Delete Item
- **URL**: `/items/{id}`
- **Method**: `DELETE`
- **Auth Required**: Yes
- **Success Response**:
- **Code**: 204 NO CONTENT
- **Error Response**:
- **Code**: 404 NOT FOUND
- **Content**: "Item not found"
#### Get Items in Box
- **URL**: `/boxes/{id}/items`
- **Method**: `GET`
- **Auth Required**: Yes
- **Success Response**:
- **Code**: 200
- **Content**: Array of item objects
- **Error Response**:
- **Code**: 404 NOT FOUND
- **Content**: "Items not found"
#### Search Items
- **URL**: `/search/items`
- **Method**: `GET`
- **Auth Required**: Yes
- **Query Parameters**:
- `q`: Search query string
- **Success Response**:
- **Code**: 200
- **Content**: Array of matching item objects
- **Error Response**:
- **Code**: 400 BAD REQUEST
- **Content**: "Search query is required"
### Item Images
#### Upload Item Image
- **URL**: `/items/{id}/upload`
- **Method**: `POST`
- **Auth Required**: Yes
- **Content-Type**: `multipart/form-data`
- **Form Parameters**:
- `image`: File upload
- **Success Response**:
- **Code**: 200
- **Content**:
```json
{
"imagePath": "string"
}
```
- **Error Responses**:
- **Code**: 404 NOT FOUND
- **Content**: "Item not found"
- **Code**: 400 BAD REQUEST
- **Content**: "Unable to parse form"
#### Get Item Image
- **URL**: `/items/{id}/image`
- **Method**: `GET`
- **Auth Required**: Yes
- **Success Response**:
- **Code**: 200
- **Content**: Image file
- **Note**: Returns default image if no image is found
### Tags
#### Get All Tags
- **URL**: `/tags`
- **Method**: `GET`
- **Auth Required**: Yes
- **Success Response**:
- **Code**: 200
- **Content**:
```json
[
{
"ID": "number",
"CreatedAt": "timestamp",
"UpdatedAt": "timestamp",
"DeletedAt": "timestamp|null",
"name": "string",
"description": "string",
"color": "string" // hex color code
}
]
```
#### Create Tag
- **URL**: `/tags`
- **Method**: `POST`
- **Auth Required**: Yes
- **Request Body**:
```json
{
"name": "string",
"description": "string",
"color": "string" // hex color code, optional
}
```
- **Success Response**:
- **Code**: 200
- **Content**: Created tag object
- **Error Responses**:
- **Code**: 400 BAD REQUEST
- **Content**: "Tag name is required"
- **Code**: 409 CONFLICT
- **Content**: "Tag name already exists"
#### Update Tag
- **URL**: `/tags/{id}`
- **Method**: `PUT`
- **Auth Required**: Yes
- **Request Body**:
```json
{
"name": "string",
"description": "string",
"color": "string"
}
```
- **Success Response**:
- **Code**: 200
- **Content**: Updated tag object
- **Error Responses**:
- **Code**: 404 NOT FOUND
- **Content**: "Tag not found"
- **Code**: 409 CONFLICT
- **Content**: "Tag name already exists"
#### Delete Tag
- **URL**: `/tags/{id}`
- **Method**: `DELETE`
- **Auth Required**: Yes
- **Success Response**:
- **Code**: 204 NO CONTENT
- **Error Response**:
- **Code**: 404 NOT FOUND
- **Content**: "Tag not found"
### Item Tags
#### Add Tags to Item
- **URL**: `/items/{id}/tags`
- **Method**: `POST`
- **Auth Required**: Yes
- **Request Body**:
```json
[1, 2, 3] // Array of tag IDs
```
- **Success Response**:
- **Code**: 200
- **Content**: Updated item object with tags
- **Error Responses**:
- **Code**: 404 NOT FOUND
- **Content**: "Item not found"
- **Code**: 400 BAD REQUEST
- **Content**: "Tag {id} not found"
#### Remove Tag from Item
- **URL**: `/items/{id}/tags/{tagId}`
- **Method**: `DELETE`
- **Auth Required**: Yes
- **Success Response**:
- **Code**: 204 NO CONTENT
- **Error Responses**:
- **Code**: 404 NOT FOUND
- **Content**: "Item not found" or "Tag not found"
#### Get Items by Tag
- **URL**: `/tags/{id}/items`
- **Method**: `GET`
- **Auth Required**: Yes
- **Success Response**:
- **Code**: 200
- **Content**:
```json
[
{
"ID": "number",
"CreatedAt": "timestamp",
"UpdatedAt": "timestamp",
"DeletedAt": "timestamp|null",
"name": "string",
"description": "string",
"box_id": "number",
"image_path": "string",
"tags": [
{
"ID": "number",
"name": "string",
"description": "string",
"color": "string"
}
]
}
]
```
- **Error Response**:
- **Code**: 404 NOT FOUND
- **Content**: "Tag not found"
### Admin Endpoints
#### Get All Users
- **URL**: `/admin/user`
- **Method**: `GET`
- **Auth Required**: Yes
- **Success Response**:
- **Code**: 200
- **Content**: Array of user objects
#### Create User
- **URL**: `/admin/user`
- **Method**: `POST`
- **Auth Required**: Yes
- **Request Body**:
```json
{
"username": "string",
"password": "string",
"email": "string"
}
```
- **Success Response**:
- **Code**: 200
- **Content**: Created user object
#### Get User
- **URL**: `/admin/user/{id}`
- **Method**: `GET`
- **Auth Required**: Yes
- **Success Response**:
- **Code**: 200
- **Content**: User object
- **Error Response**:
- **Code**: 404 NOT FOUND
- **Content**: "User not found"
#### Delete User
- **URL**: `/admin/user/{id}`
- **Method**: `DELETE`
- **Auth Required**: Yes
- **Success Response**:
- **Code**: 204 NO CONTENT
- **Error Response**:
- **Code**: 404 NOT FOUND
- **Content**: "User not found"
#### Backup Database
- **URL**: `/admin/db`
- **Method**: `GET`
- **Auth Required**: Yes
- **Success Response**:
- **Code**: 200
- **Content**: Database file
#### Restore Database
- **URL**: `/admin/db`
- **Method**: `POST`
- **Auth Required**: Yes
- **Content-Type**: `multipart/form-data`
- **Form Parameters**:
- `database`: Database file
- **Success Response**:
- **Code**: 200
- **Content**:
```json
{
"message": "Database restored successfully"
} }
``` ```
* **GET /items/{id}:** ## Error Responses
* Retrieves the item with the specified ID. All endpoints may return these common errors:
* Response: Item object - **401 Unauthorized**: Missing or invalid authentication token
* **GET /boxes/{id}/items:** - **500 Internal Server Error**: Server-side error
* Retrieves all items within the box with the specified ID.
* Response: Array of Item objects
* **PUT /items/{id}:**
* Updates the item with the specified ID.
* Request body: JSON object with updated item details
* Response: Updated Item object
* **DELETE /items/{id}:**
* Deletes the item with the specified ID.
* Response: 204 No Content

191
auth.go Normal file
View File

@ -0,0 +1,191 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/dgrijalva/jwt-go"
"golang.org/x/crypto/bcrypt"
)
// Define contextKey globally within the package
type contextKey string
// Define your key as a constant of the custom type
const userKey contextKey = "user"
// LoginRequest represents the request body for the /login endpoint.
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// LoginResponse represents the response body for the /login endpoint.
type LoginResponse struct {
Token string `json:"token"`
}
func init() {
log := GetLogger()
if log != nil {
log.Info("Initializing authentication module")
log.Debug("Current config: %+v", config)
}
}
// LoginHandler handles the /login endpoint.
func LoginHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
var req LoginRequest
if db == nil {
if log != nil {
log.Error("Database connection not initialized in LoginHandler")
}
http.Error(w, "Database not initialized", http.StatusInternalServerError)
return
}
if log != nil {
log.Info("Processing login request")
}
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
if log != nil {
log.Error("Failed to decode login request: %v", err)
}
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Check if the user exists and the password matches
var user User
result := db.Where("username = ?", req.Username).First(&user)
if result.Error != nil || user.ID == 0 {
if log != nil {
log.Warn("Login attempt failed for username: %s - user not found", req.Username)
}
http.Error(w, "Invalid username or password", http.StatusUnauthorized)
return
}
// Compare the provided password with the stored hashed password
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password))
if err != nil {
if log != nil {
log.Warn("Login attempt failed for username: %s - invalid password", req.Username)
}
http.Error(w, "Invalid username or password", http.StatusUnauthorized)
return
}
// Generate JWT token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"username": user.Username,
"exp": time.Now().Add(time.Hour * 24).Unix(), // Token expires in 24 hours
})
tokenString, err := token.SignedString(*JWTSecret)
if err != nil {
if log != nil {
log.Error("Failed to generate JWT token for user %s: %v", user.Username, err)
}
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
if log != nil {
log.Info("Successful login for user: %s", user.Username)
log.UserAction(user.Username, "login")
}
// Return the token in the response
json.NewEncoder(w).Encode(LoginResponse{Token: tokenString})
}
// authMiddleware is a middleware function that checks for a valid JWT token in the request header and enables CORS.
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
// Set CORS headers
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// Handle preflight request for CORS
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
// Check if this is a browser requesting HTML
acceptHeader := r.Header.Get("Accept")
isBrowserRequest := strings.Contains(acceptHeader, "text/html")
// If it's a browser request for HTML, always serve the React app
if isBrowserRequest {
http.Redirect(w, r, "/index.html", http.StatusSeeOther)
return
}
// From here on, we're dealing with actual API requests
tokenString := r.Header.Get("Authorization")
if tokenString == "" {
if log != nil {
log.Warn("Request rejected: missing Authorization header for path: %s", r.URL.Path)
}
http.Error(w, "Authorization header missing", http.StatusUnauthorized)
return
}
// Remove "Bearer " prefix from token string
tokenString = strings.Replace(tokenString, "Bearer ", "", 1)
// Parse and validate the JWT token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Make sure that the signing method is HMAC
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
if log != nil {
log.Warn("Invalid signing method in token: %v", token.Header["alg"])
}
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return *JWTSecret, nil
})
if err != nil || !token.Valid {
if log != nil {
log.Warn("Invalid token: %v", err)
}
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
// Extract the user claims from the token
if claims, ok := token.Claims.(jwt.MapClaims); ok {
username := claims["username"].(string)
// Add the "user" claim to the request context
newCtx := context.WithValue(r.Context(), userKey, username)
r = r.WithContext(newCtx)
if log != nil {
log.Debug("Authenticated request for user: %s", username)
}
} else {
if log != nil {
log.Warn("Invalid token claims structure")
}
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
return
}
// Call the next handler in the chain
next.ServeHTTP(w, r)
})
}

BIN
boxes-api

Binary file not shown.

10
boxes.log Normal file
View File

@ -0,0 +1,10 @@
2024/10/26 20:47:15 main.go:94: Logging initialized
2024/10/26 20:47:15 main.go:106: Connected to database in connectToDatabase
2024/10/26 20:47:15 main.go:116: Static directory error: static directory does not exist: ./build/
2024/10/26 20:47:52 main.go:94: Logging initialized
2024/10/26 20:47:52 main.go:106: Connected to database in connectToDatabase
2024/10/26 20:47:52 main.go:116: Static directory error: static directory does not exist: ./build/
2024/10/26 20:49:06 main.go:94: Logging initialized
2024/10/26 20:49:06 main.go:106: Connected to database in connectToDatabase
2024/10/26 22:19:11 main.go:94: Logging initialized
2024/10/26 22:19:11 main.go:106: Connected to database in connectToDatabase

82
boxes_handlers.go Normal file
View File

@ -0,0 +1,82 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"github.com/gorilla/mux"
)
// getBoxesHandler handles the GET /boxes endpoint.
func GetBoxesHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
log.Info("Received %s request to %s", r.Method, r.URL)
var boxes []Box
db.Find(&boxes)
json.NewEncoder(w).Encode(boxes)
}
func GetBoxHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
vars := mux.Vars(r)
id := vars["id"]
var box Box
if err := db.First(&box, id).Error; err != nil {
log.Warn("Box not found with ID: %s", id)
http.Error(w, "Box not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(box)
}
// createBoxHandler handles the POST /boxes endpoint.
func CreateBoxHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
var box Box
err := json.NewDecoder(r.Body).Decode(&box)
if err != nil {
log.Error("Failed to decode box creation request: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
db.Create(&box)
// Create a response struct to include the ID
type createBoxResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
}
response := createBoxResponse{
ID: box.ID,
Name: box.Name,
}
log.DatabaseAction("create", fmt.Sprintf("Created box with ID %d", box.ID))
json.NewEncoder(w).Encode(response)
}
// deleteBoxHandler handles the DELETE /boxes/{id} endpoint.
func DeleteBoxHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
vars := mux.Vars(r)
id := vars["id"]
// Retrieve the box from the database
var box Box
if err := db.First(&box, id).Error; err != nil {
log.Warn("Attempt to delete non-existent box with ID: %s", id)
http.Error(w, "Box not found", http.StatusNotFound)
return
}
// Delete the box
db.Delete(&box)
log.DatabaseAction("delete", fmt.Sprintf("Deleted box with ID %d", box.ID))
w.WriteHeader(http.StatusNoContent) // 204 No Content
}

View File

@ -16,6 +16,10 @@ type Config struct {
ImageStorageDir string `yaml:"image_storage_dir"` ImageStorageDir string `yaml:"image_storage_dir"`
ListeningPort int `yaml:"listening_port"` ListeningPort int `yaml:"listening_port"`
LogFile string `yaml:"log_file"` LogFile string `yaml:"log_file"`
LogLevel string `yaml:"log_level"`
LogOutput string `yaml:"log_output"`
StaticFilesDir string `yaml:"static_files_dir"`
AllowedOrigins string `yaml:"allowed_origins"`
} }
func LoadConfig(configPath string) (*Config, error) { func LoadConfig(configPath string) (*Config, error) {
@ -37,5 +41,13 @@ func LoadConfig(configPath string) (*Config, error) {
config.DatabasePath = dbPath config.DatabasePath = dbPath
} }
if config.StaticFilesDir == "" {
config.StaticFilesDir = "build"
}
if config.AllowedOrigins == "" {
config.AllowedOrigins = "http://localhost:8080"
}
return &config, nil return &config, nil
} }

0
config/boxes.db Normal file
View File

View File

@ -1,6 +1,10 @@
database_path: "data/boxes.db" database_path: "/app/data/boxes.db"
test_database_path: "data/test_database.db" test_database_path: "/app/data/test_database.db"
jwt_secret: "super_secret_key" jwt_secret: "super_secret_key"
image_storage_dir: "images" image_storage_dir: "/app/images"
listening_port: 8080 listening_port: 8080
log_file: "boxes.log" log_file: "/app/data/boxes.log"
log_level: "INFO"
log_output: "both" # Can be "file", "stdout", or "both"
static_files_dir: "/app/build/"
allowed_origins: "*"

8
config/config_local.yaml Normal file
View File

@ -0,0 +1,8 @@
database_path: "./data/boxes.db"
test_database_path: "./data/test_database.db"
jwt_secret: "super_secret_key"
image_storage_dir: "./images/"
listening_port: 8080
log_file: "boxes.log"
static_files_dir: "./build/"
allowed_origins: "*"

23
db.go
View File

@ -13,6 +13,21 @@ type Box struct {
Name string `json:"name"` Name string `json:"name"`
} }
// Define the Tag model
type Tag struct {
gorm.Model
Name string `json:"name" gorm:"uniqueIndex"`
Description string `json:"description"`
Color string `json:"color" gorm:"default:'#808080'"` // Hex color code
Items []Item `gorm:"many2many:item_tags;"`
}
// ItemTag represents the many-to-many relationship between Items and Tags
type ItemTag struct {
ItemID uint `gorm:"primaryKey"`
TagID uint `gorm:"primaryKey"`
}
// Define the Item model // Define the Item model
type Item struct { type Item struct {
gorm.Model gorm.Model
@ -20,6 +35,7 @@ type Item struct {
Description string `json:"description"` Description string `json:"description"`
BoxID uint `json:"box_id"` BoxID uint `json:"box_id"`
ImagePath string `json:"image_path"` ImagePath string `json:"image_path"`
Tags []Tag `json:"tags" gorm:"many2many:item_tags;"`
} }
// Define the User model // Define the User model
@ -27,6 +43,7 @@ type User struct {
gorm.Model gorm.Model
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Email string `json:"email"`
} }
func ConnectDB(dbPath string) (*gorm.DB, error) { func ConnectDB(dbPath string) (*gorm.DB, error) {
@ -35,8 +52,12 @@ func ConnectDB(dbPath string) (*gorm.DB, error) {
return nil, fmt.Errorf("failed to connect to database: %v", err) return nil, fmt.Errorf("failed to connect to database: %v", err)
} }
// set auto_vacuum mode to ON
// this automagically removes old rows from the database when idle
db.Exec("PRAGMA auto_vacuum = ON;")
// AutoMigrate will create the tables if they don't exist // AutoMigrate will create the tables if they don't exist
db.AutoMigrate(&Box{}, &Item{}, &User{}) db.AutoMigrate(&Box{}, &Item{}, &User{}, &Tag{}, &ItemTag{})
return db, nil return db, nil
} }

22
docker-compose.yml Normal file
View File

@ -0,0 +1,22 @@
version: '3.3' # Specify the Docker Compose file version
services:
boxes-api:
image: localhost/boxes-api:latest # Use the existing boxes-api image
#container_name: boxes-api # Name the container
ports:
- "8080:8080" # Map host port 8080 to container port 8080
environment:
BOXES_API_CONFIG: "/app/config/config.yaml" # Set the CONFIG environment variable
volumes:
- /home/stwhite/dockerboxes/boxes-api/data:/app/data # Mount host data directory
- /home/stwhite/dockerboxes/boxes-api/images:/app/images # Mount host images directory
- /home/stwhite/dockerboxes/boxes-api/config:/app/config # Mount host config directory
- /home/stwhite/dockerboxes/boxes-api/build:/app/build
networks:
- app-network
networks:
app-network:
driver: bridge

5
go.mod
View File

@ -7,14 +7,11 @@ require (
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/jinzhu/gorm v1.9.16 github.com/jinzhu/gorm v1.9.16
github.com/rs/cors v1.11.1 github.com/rs/cors v1.11.1
github.com/stretchr/testify v1.9.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/mattn/go-sqlite3 v1.14.0 // indirect github.com/mattn/go-sqlite3 v1.14.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/crypto v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

11
go.sum
View File

@ -1,7 +1,5 @@
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
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/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM= github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM=
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
@ -24,16 +22,13 @@ github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4=
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA= github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA=
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
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/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
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=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd h1:GGJVjV8waZKRHrgwvtH66z9ZGVurTD1MT0n1Bb+q4aM=
golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/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/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -46,5 +41,3 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -1,404 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/gorilla/mux"
)
// Define contextKey globally within the package
type contextKey string
// Define your key as a constant of the custom type
const userKey contextKey = "user"
// LoginRequest represents the request body for the /login endpoint.
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// LoginResponse represents the response body for the /login endpoint.
type LoginResponse struct {
Token string `json:"token"`
}
// loginHandler handles the /login endpoint.
func LoginHandler(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
fmt.Println(db, config)
fmt.Println("LoginHandler called")
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Check if the user exists and the password matches
var user User
db.Where("username = ?", req.Username).First(&user)
if user.ID == 0 || user.Password != req.Password {
http.Error(w, "Invalid username or password", http.StatusUnauthorized)
return
}
// Generate JWT token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"username": user.Username,
"exp": time.Now().Add(time.Hour * 24).Unix(), // Token expires in 24 hours
})
tokenString, err := token.SignedString([]byte(config.JWTSecret))
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
// Return the token in the response
json.NewEncoder(w).Encode(LoginResponse{Token: tokenString})
}
// getBoxesHandler handles the GET /boxes endpoint.
func GetBoxesHandler(w http.ResponseWriter, r *http.Request) {
fmt.Printf("Received %s request to %s\n", r.Method, r.URL)
var boxes []Box
db.Find(&boxes)
json.NewEncoder(w).Encode(boxes)
}
func GetBoxHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
var box Box
if err := db.First(&box, id).Error; err != nil {
http.Error(w, "Box not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(box)
}
// createBoxHandler handles the POST /boxes endpoint.
func CreateBoxHandler(w http.ResponseWriter, r *http.Request) {
var box Box
err := json.NewDecoder(r.Body).Decode(&box)
if err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
db.Create(&box)
// Create a response struct to include the ID
type createBoxResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
}
response := createBoxResponse{
ID: box.ID,
Name: box.Name,
}
json.NewEncoder(w).Encode(response)
}
// deleteBoxHandler handles the DELETE /boxes/{id} endpoint.
func DeleteBoxHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
// Retrieve the box from the database
var box Box
if err := db.First(&box, id).Error; err != nil {
http.Error(w, "Box not found", http.StatusNotFound)
return
}
// Optionally, delete associated items (if you want cascading delete)
// db.Where("box_id = ?", id).Delete(&Item{})
// Delete the box
db.Delete(&box)
w.WriteHeader(http.StatusNoContent) // 204 No Content
}
// getItemsHandler handles the GET /items endpoint.
func GetItemsHandler(w http.ResponseWriter, r *http.Request) {
var items []Item
db.Find(&items)
json.NewEncoder(w).Encode(items)
}
// createItemHandler handles the POST /items endpoint.
func CreateItemHandler(w http.ResponseWriter, r *http.Request) {
var item Item
err := json.NewDecoder(r.Body).Decode(&item)
if err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
fmt.Println(item)
db.Create(&item)
// Create a response struct to include the ID
type createItemResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
}
response := createItemResponse{
ID: item.ID,
Name: item.Name,
}
json.NewEncoder(w).Encode(response)
}
// UploadItemImageHandler handles the image upload for an item
func UploadItemImageHandler(w http.ResponseWriter, r *http.Request) {
// Extract the authenticated user from context (assuming this is how AuthMiddleware works)
user, ok := r.Context().Value(userKey).(string)
if !ok || user == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Parse the form data, 10MB limit for file uploads
err := r.ParseMultipartForm(10 << 20)
if err != nil {
http.Error(w, "Unable to parse form", http.StatusBadRequest)
return
}
// Get the file from the form data
file, handler, err := r.FormFile("image")
if err != nil {
http.Error(w, "Error retrieving the file", http.StatusBadRequest)
return
}
defer file.Close()
// Get item ID from the URL
vars := mux.Vars(r)
itemID := vars["id"]
// Validate that the item exists (fetch from DB using itemID)
var item Item
if err := db.First(&item, itemID).Error; err != nil {
http.Error(w, "Item not found", http.StatusNotFound)
return
}
// Save the uploaded file locally or to a storage service
// Ensure the directory exists
if err := os.MkdirAll(config.ImageStorageDir, 0755); err != nil {
http.Error(w, "Unable to create image storage directory", http.StatusInternalServerError)
return
}
filePath := fmt.Sprintf("%s/%s", config.ImageStorageDir, handler.Filename)
outFile, err := os.Create(filePath)
if err != nil {
http.Error(w, "Unable to save the file", http.StatusInternalServerError)
return
}
defer outFile.Close()
// Copy the uploaded file to the destination
_, err = io.Copy(outFile, file)
if err != nil {
http.Error(w, "Unable to save the file", http.StatusInternalServerError)
return
}
// Update the item record in the database with the image path
item.ImagePath = filePath
if err := db.Save(&item).Error; err != nil {
http.Error(w, "Unable to save image path in database", http.StatusInternalServerError)
return
}
fmt.Println("Image upload called")
// Return the image path in the response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"imagePath": filePath})
}
// GetItemImageHandler retrieves an item's image by item ID.
func GetItemImageHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
itemID := vars["id"]
fmt.Println("Getting image")
// Retrieve the item from the database
var item Item
if err := db.First(&item, itemID).Error; err != nil {
item.ImagePath = "images/default.jpg"
} else if item.ImagePath == "" {
item.ImagePath = "images/default.jpg"
}
// Open the image file
imageFile, err := os.Open(item.ImagePath)
if err != nil {
// Log the error for debugging, but don't return an HTTP error
fmt.Println("Error opening image.", err)
item.ImagePath = "images/default.jpg"
return
}
defer imageFile.Close()
// Determine the content type of the image
imageData, err := io.ReadAll(imageFile)
if err != nil {
fmt.Println("Error reading image")
item.ImagePath = "images/default.jpg"
return
}
contentType := http.DetectContentType(imageData)
// Set the content type header and write the image data to the response
w.Header().Set("Content-Type", contentType)
w.Write(imageData)
}
// searchItemsHandler handles the GET /items/search endpoint.
func SearchItemsHandler(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "Search query is required", http.StatusBadRequest)
return
}
fmt.Println(query)
var items []Item
db.Where("name GLOB ? OR description GLOB ?", "*"+query+"*", "*"+query+"*").Find(&items)
json.NewEncoder(w).Encode(items)
}
// getItemHandler handles the GET /items/{id} endpoint.
func GetItemHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
var item Item
if err := db.First(&item, id).Error; err != nil {
http.Error(w, "Item not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(item)
}
// getItemsInBoxHandler handles the GET /boxes/{id}/items endpoint.
func GetItemsInBoxHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
var items []Item
if err := db.Where("box_id = ?", id).Find(&items).Error; err != nil {
http.Error(w, "Items not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(items)
}
// updateItemHandler handles the PUT /items/{id} endpoint.
func UpdateItemHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
var item Item
if err := db.First(&item, id).Error; err != nil {
http.Error(w, "Item not found", http.StatusNotFound)
return
}
err := json.NewDecoder(r.Body).Decode(&item)
if err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
fmt.Println(item)
db.Save(&item)
json.NewEncoder(w).Encode(item)
}
// deleteItemHandler handles the DELETE /items/{id} endpoint.
func DeleteItemHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
var item Item
if err := db.First(&item, id).Error; err != nil {
http.Error(w, "Item not found", http.StatusNotFound)
return
}
db.Delete(&item)
w.WriteHeader(http.StatusNoContent)
}
// authMiddleware is a middleware function that checks for a valid JWT token in the request header and enables CORS.
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Set CORS headers
w.Header().Set("Access-Control-Allow-Origin", "*") // Replace "*" with your allowed frontend origin if needed
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// Handle preflight request for CORS
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
// Get the token from the request header
tokenString := r.Header.Get("Authorization")
if tokenString == "" {
http.Error(w, "Authorization header missing", http.StatusUnauthorized)
return
}
// Remove "Bearer " prefix from token string
tokenString = strings.Replace(tokenString, "Bearer ", "", 1)
// Parse and validate the JWT token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Make sure that the signing method is HMAC
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(config.JWTSecret), nil
})
if err != nil || !token.Valid {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
// Extract the user claims from the token
if claims, ok := token.Claims.(jwt.MapClaims); ok {
// Add the "user" claim to the request context
newCtx := context.WithValue(r.Context(), userKey, claims["username"])
r = r.WithContext(newCtx)
} else {
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
return
}
// Call the next handler in the chain
next.ServeHTTP(w, r)
})
}

242
item_handlers.go Normal file
View File

@ -0,0 +1,242 @@
// item_handlers.go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"github.com/gorilla/mux"
)
func GetItemsHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
log.Info("Received %s request to %s", r.Method, r.URL)
var items []Item
// Preload tags when getting items
if err := db.Preload("Tags").Find(&items).Error; err != nil {
log.Error("Failed to fetch items: %v", err)
http.Error(w, "Failed to fetch items", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(items)
}
func CreateItemHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
var item Item
err := json.NewDecoder(r.Body).Decode(&item)
if err != nil {
log.Error("Failed to decode item creation request: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
log.Info("Creating new item: %s", item.Name)
db.Create(&item)
type createItemResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
}
response := createItemResponse{
ID: item.ID,
Name: item.Name,
}
log.DatabaseAction("create", fmt.Sprintf("Created item with ID %d", item.ID))
json.NewEncoder(w).Encode(response)
}
func UploadItemImageHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
user, ok := r.Context().Value(userKey).(string)
if !ok || user == "" {
log.Warn("Unauthorized image upload attempt")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
err := r.ParseMultipartForm(10 << 20)
if err != nil {
log.Error("Failed to parse multipart form: %v", err)
http.Error(w, "Unable to parse form", http.StatusBadRequest)
return
}
file, handler, err := r.FormFile("image")
if err != nil {
log.Error("Error retrieving file from form: %v", err)
http.Error(w, "Error retrieving the file", http.StatusBadRequest)
return
}
defer file.Close()
vars := mux.Vars(r)
itemID := vars["id"]
var item Item
if err := db.First(&item, itemID).Error; err != nil {
log.Warn("Attempt to upload image for non-existent item with ID: %s", itemID)
http.Error(w, "Item not found", http.StatusNotFound)
return
}
if err := os.MkdirAll(*ImageStorage, 0755); err != nil {
log.Error("Failed to create image storage directory: %v", err)
http.Error(w, "Unable to create image storage directory", http.StatusInternalServerError)
return
}
filePath := fmt.Sprintf("%s/%s", *ImageStorage, handler.Filename)
outFile, err := os.Create(filePath)
if err != nil {
log.Error("Failed to create file: %v", err)
http.Error(w, "Unable to save the file", http.StatusInternalServerError)
return
}
defer outFile.Close()
_, err = io.Copy(outFile, file)
if err != nil {
log.Error("Failed to save file: %v", err)
http.Error(w, "Unable to save the file", http.StatusInternalServerError)
return
}
item.ImagePath = filePath
if err := db.Save(&item).Error; err != nil {
log.Error("Failed to update item with image path: %v", err)
http.Error(w, "Unable to save image path in database", http.StatusInternalServerError)
return
}
log.Info("Image uploaded successfully for item %s by user %s", itemID, user)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"imagePath": filePath})
}
func GetItemImageHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
vars := mux.Vars(r)
itemID := vars["id"]
var item Item
if err := db.First(&item, itemID).Error; err != nil {
log.Info("Item not found, using default image for ID: %s", itemID)
item.ImagePath = "images/default.jpg"
} else if item.ImagePath == "" {
item.ImagePath = "images/default.jpg"
}
imageFile, err := os.Open(item.ImagePath)
if err != nil {
log.Error("Error opening image file: %v", err)
item.ImagePath = "images/default.jpg"
return
}
defer imageFile.Close()
imageData, err := io.ReadAll(imageFile)
if err != nil {
log.Error("Error reading image file: %v", err)
item.ImagePath = "images/default.jpg"
return
}
contentType := http.DetectContentType(imageData)
w.Header().Set("Content-Type", contentType)
w.Write(imageData)
}
func SearchItemsHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
query := r.URL.Query().Get("q")
if query == "" {
log.Warn("Search attempt with empty query")
http.Error(w, "Search query is required", http.StatusBadRequest)
return
}
log.Info("Searching for items with query: %s", query)
var items []Item
db.Where("name GLOB ? OR description GLOB ?", "*"+query+"*", "*"+query+"*").Find(&items)
json.NewEncoder(w).Encode(items)
}
func GetItemHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
vars := mux.Vars(r)
id := vars["id"]
var item Item
if err := db.Preload("Tags").First(&item, id).Error; err != nil {
log.Warn("Item not found with ID: %s", id)
http.Error(w, "Item not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(item)
}
func GetItemsInBoxHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
vars := mux.Vars(r)
id := vars["id"]
var items []Item
if err := db.Preload("Tags").Where("box_id = ?", id).Find(&items).Error; err != nil {
log.Warn("Failed to fetch items for box ID: %s", id)
http.Error(w, "Items not found", http.StatusNotFound)
return
}
log.Info("Retrieved %d items from box %s", len(items), id)
json.NewEncoder(w).Encode(items)
}
func UpdateItemHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
vars := mux.Vars(r)
id := vars["id"]
var item Item
if err := db.First(&item, id).Error; err != nil {
log.Warn("Attempt to update non-existent item with ID: %s", id)
http.Error(w, "Item not found", http.StatusNotFound)
return
}
err := json.NewDecoder(r.Body).Decode(&item)
if err != nil {
log.Error("Failed to decode item update request: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
log.DatabaseAction("update", fmt.Sprintf("Updated item with ID %s", id))
db.Save(&item)
json.NewEncoder(w).Encode(item)
}
func DeleteItemHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
vars := mux.Vars(r)
id := vars["id"]
var item Item
if err := db.First(&item, id).Error; err != nil {
log.Warn("Attempt to delete non-existent item with ID: %s", id)
http.Error(w, "Item not found", http.StatusNotFound)
return
}
log.DatabaseAction("delete", fmt.Sprintf("Deleted item with ID %s", id))
db.Delete(&item)
w.WriteHeader(http.StatusNoContent)
}

213
logger.go Normal file
View File

@ -0,0 +1,213 @@
// logger.go
package main
import (
"fmt"
"io"
"log"
"os"
"runtime"
"time"
)
type Logger struct {
*log.Logger
writers []io.Writer
level LogLevel
}
type LogLevel int
const (
DEBUG LogLevel = iota
INFO
WARN
ERROR
)
var (
instance *Logger
// Map for converting string to LogLevel
logLevelMap = map[string]LogLevel{
"DEBUG": DEBUG,
"INFO": INFO,
"WARN": WARN,
"ERROR": ERROR,
}
)
// Initialize creates a new logger instance with specified configuration
func Initialize(logPath string, logOutput string) (*Logger, error) {
if instance != nil {
return instance, nil
}
var writers []io.Writer
// Handle different output configurations
switch logOutput {
case "stdout":
writers = append(writers, os.Stdout)
case "file":
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return nil, fmt.Errorf("failed to open log file: %v", err)
}
writers = append(writers, file)
case "both":
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return nil, fmt.Errorf("failed to open log file: %v", err)
}
writers = append(writers, file, os.Stdout)
default:
// Default to stdout if invalid option provided
writers = append(writers, os.Stdout)
}
// Create multi-writer if we have multiple writers
var writer io.Writer
if len(writers) > 1 {
writer = io.MultiWriter(writers...)
} else {
writer = writers[0]
}
// Create logger instance
logger := &Logger{
Logger: log.New(writer, "", 0),
writers: writers,
level: INFO, // Default to INFO level
}
instance = logger
return logger, nil
}
// SetLogLevel sets the logging level from a string
func (l *Logger) SetLogLevel(levelStr string) error {
level, ok := logLevelMap[levelStr]
if !ok {
return fmt.Errorf("invalid log level: %s. Must be one of: DEBUG, INFO, WARN, ERROR", levelStr)
}
l.level = level
return nil
}
// GetLogLevel returns the current logging level as a string
func (l *Logger) GetLogLevel() string {
for str, level := range logLevelMap {
if level == l.level {
return str
}
}
return "UNKNOWN"
}
// GetLogger returns the singleton logger instance
func GetLogger() *Logger {
return instance
}
// Close closes all writers that implement io.Closer
func (l *Logger) Close() error {
var errs []error
for _, writer := range l.writers {
if closer, ok := writer.(io.Closer); ok {
if err := closer.Close(); err != nil {
errs = append(errs, err)
}
}
}
if len(errs) > 0 {
return fmt.Errorf("errors closing writers: %v", errs)
}
return nil
}
func (l *Logger) log(level LogLevel, format string, v ...interface{}) {
if l == nil {
return
}
if level < l.level {
return
}
// Get caller information
_, file, line, _ := runtime.Caller(2)
// Create timestamp
timestamp := time.Now().Format("2006-01-02 15:04:05")
// Create level string
levelStr := "INFO"
switch level {
case DEBUG:
levelStr = "DEBUG"
case WARN:
levelStr = "WARN"
case ERROR:
levelStr = "ERROR"
}
// Format message
message := fmt.Sprintf(format, v...)
// Final log format
logLine := fmt.Sprintf("[%s] [%s] [%s:%d] %s\n",
timestamp,
levelStr,
file,
line,
message,
)
l.Logger.Print(logLine)
}
// Debug logs a debug message
func (l *Logger) Debug(format string, v ...interface{}) {
l.log(DEBUG, format, v...)
}
// Info logs an info message
func (l *Logger) Info(format string, v ...interface{}) {
l.log(INFO, format, v...)
}
// Warn logs a warning message
func (l *Logger) Warn(format string, v ...interface{}) {
l.log(WARN, format, v...)
}
// Error logs an error message
func (l *Logger) Error(format string, v ...interface{}) {
l.log(ERROR, format, v...)
}
// HTTPRequest logs an HTTP request
func (l *Logger) HTTPRequest(method, path, username string, statusCode int) {
l.log(INFO, "HTTP %s %s - User: %s - Status: %d",
method,
path,
username,
statusCode,
)
}
// UserAction logs user actions like login, logout, etc.
func (l *Logger) UserAction(username, action string) {
l.log(INFO, "User Action - Username: %s - Action: %s",
username,
action,
)
}
// DatabaseAction logs database operations
func (l *Logger) DatabaseAction(operation, details string) {
l.log(INFO, "Database Action - Operation: %s - Details: %s",
operation,
details,
)
}

360
main.go
View File

@ -5,75 +5,339 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"github.com/rs/cors" "github.com/rs/cors"
) )
// Global variables
var ( var (
db *gorm.DB // Declare db globally db *gorm.DB
config *Config config *Config
JWTSecret *[]byte
ImageStorage *string
DatabasePath *string
) )
func main() { func main() {
// Load configuration first
configFile := os.Getenv("CONFIG")
var err error var err error
config, err = LoadConfig(configFile) config, err = loadAndValidateConfig()
if err != nil {
// check for errors
if err != nil || config == nil {
log.Fatalf("Failed to load config: %v", err) log.Fatalf("Failed to load config: %v", err)
} }
fmt.Println(config.DatabasePath) // Initialize logger with config values
fmt.Println(config.ImageStorageDir) logger, err := Initialize(config.LogFile, "both") // "both" means log to both file and stdout
fmt.Println(config.JWTSecret) if err != nil {
fmt.Println(config.LogFile) log.Fatalf("Failed to initialize logger: %v", err)
fmt.Println(config.ListeningPort) }
defer logger.Close()
// Connect to the database // Set up logging configuration
db, err = ConnectDB(config.DatabasePath) if err := setupLogging(config.LogLevel); err != nil {
if err != nil || db == nil { logger.Error("Failed to set up logging: %v", err)
log.Fatalf("Failed to connect to database: %v", err) os.Exit(1)
}
// Log configuration details
logConfigDetails(config)
// Connect to database
db, err = connectToDatabase(config)
if err != nil {
logger.Error("Failed to connect to database: %v", err)
os.Exit(1)
} }
defer db.Close() defer db.Close()
fmt.Println("Default user 'boxuser' created successfully!") // Create and configure the router
router := createRouter()
// Create the router // Create CORS handler
router := mux.NewRouter() corsHandler := createCORSHandler(config.AllowedOrigins)
// Define your routes // Start the server
router.Handle("/login", http.HandlerFunc(LoginHandler)).Methods("POST", "OPTIONS") startServer(config.ListeningPort, corsHandler(router))
router.Handle("/boxes", AuthMiddleware(http.HandlerFunc(GetBoxesHandler))).Methods("GET", "OPTIONS") }
router.Handle("/boxes", AuthMiddleware(http.HandlerFunc(CreateBoxHandler))).Methods("POST", "OPTIONS")
router.Handle("/boxes/{id}", AuthMiddleware(http.HandlerFunc(DeleteBoxHandler))).Methods("DELETE", "OPTIONS")
router.Handle("/boxes/{id}", AuthMiddleware(http.HandlerFunc(GetBoxHandler))).Methods("GET", "OPTIONS")
router.Handle("/items", AuthMiddleware(http.HandlerFunc(GetItemsHandler))).Methods("GET", "OPTIONS")
router.Handle("/items", AuthMiddleware(http.HandlerFunc(CreateItemHandler))).Methods("POST", "OPTIONS")
router.Handle("/items/{id}", AuthMiddleware(http.HandlerFunc(GetItemHandler))).Methods("GET", "OPTIONS")
router.Handle("/boxes/{id}/items", AuthMiddleware(http.HandlerFunc(GetItemsInBoxHandler))).Methods("GET", "OPTIONS")
router.Handle("/items/{id}", AuthMiddleware(http.HandlerFunc(UpdateItemHandler))).Methods("PUT", "OPTIONS")
router.Handle("/items/{id}", AuthMiddleware(http.HandlerFunc(DeleteItemHandler))).Methods("DELETE", "OPTIONS")
router.Handle("/items/{id}/image", AuthMiddleware(http.HandlerFunc(GetItemImageHandler))).Methods("GET", "OPTIONS")
fmt.Println("Registering route for search items...")
router.Handle("/search/items", AuthMiddleware(http.HandlerFunc(SearchItemsHandler))).Methods("GET", "OPTIONS")
// Add a new route for uploading an image with AuthMiddleware
router.HandleFunc("/items/{id}/upload", UploadItemImageHandler).
Methods("POST").
Handler(AuthMiddleware(http.HandlerFunc(UploadItemImageHandler)))
// Apply CORS middleware func createRouter() *mux.Router {
c := cors.New(cors.Options{ baseRouter := mux.NewRouter()
AllowedOrigins: []string{"http://localhost:3000", "http://10.0.0.16:3000"}, // Change this to your frontend domain baseRouter.Use(loggingMiddleware)
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type"}, // API routes should be registered first with a strict prefix match
AllowCredentials: true, apiRouter := baseRouter.PathPrefix("/api/v1").Subrouter()
apiRouter.StrictSlash(true) // This ensures /api/v1/ and /api/v1 are treated the same
setupAPIRoutes(apiRouter)
// Static file serving should be last and only match if no API routes matched
staticRouter := baseRouter.NewRoute().Subrouter()
setupStaticRoutes(staticRouter, config.StaticFilesDir)
// Add a catch-all NotFoundHandler to the base router
baseRouter.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// If the path starts with /api, return a 404 API error
if strings.HasPrefix(r.URL.Path, "/api/") {
http.Error(w, "API endpoint not found", http.StatusNotFound)
return
}
// Otherwise serve the index.html for client-side routing
http.ServeFile(w, r, filepath.Join(config.StaticFilesDir, "index.html"))
}) })
// Start the server with CORS middleware return baseRouter
fmt.Printf("Server listening on port %d\n", config.ListeningPort) }
http.ListenAndServe(fmt.Sprintf(":%d", config.ListeningPort), c.Handler(router))
func setupAPIRoutes(router *mux.Router) {
// Public routes
router.Handle("/login", http.HandlerFunc(LoginHandler)).Methods("POST", "OPTIONS")
// Protected routes
protected := router.NewRoute().Subrouter()
protected.Use(AuthMiddleware)
// Box routes
protected.Handle("/boxes", http.HandlerFunc(GetBoxesHandler)).Methods("GET", "OPTIONS")
protected.Handle("/boxes", http.HandlerFunc(CreateBoxHandler)).Methods("POST", "OPTIONS")
protected.Handle("/boxes/{id}", http.HandlerFunc(GetBoxHandler)).Methods("GET", "OPTIONS")
protected.Handle("/boxes/{id}", http.HandlerFunc(DeleteBoxHandler)).Methods("DELETE", "OPTIONS")
protected.Handle("/boxes/{id}/items", http.HandlerFunc(GetItemsInBoxHandler)).Methods("GET", "OPTIONS")
// Item routes
protected.Handle("/items", http.HandlerFunc(GetItemsHandler)).Methods("GET", "OPTIONS")
protected.Handle("/items", http.HandlerFunc(CreateItemHandler)).Methods("POST", "OPTIONS")
protected.Handle("/items/{id}", http.HandlerFunc(GetItemHandler)).Methods("GET", "OPTIONS")
protected.Handle("/items/{id}", http.HandlerFunc(UpdateItemHandler)).Methods("PUT", "OPTIONS")
protected.Handle("/items/{id}", http.HandlerFunc(DeleteItemHandler)).Methods("DELETE", "OPTIONS")
protected.Handle("/items/{id}/image", http.HandlerFunc(GetItemImageHandler)).Methods("GET", "OPTIONS")
protected.Handle("/items/{id}/upload", http.HandlerFunc(UploadItemImageHandler)).Methods("POST", "OPTIONS")
protected.Handle("/search/items", http.HandlerFunc(SearchItemsHandler)).Methods("GET", "OPTIONS")
// Tag routes
protected.Handle("/tags", http.HandlerFunc(GetTagsHandler)).Methods("GET", "OPTIONS")
protected.Handle("/tags", http.HandlerFunc(CreateTagHandler)).Methods("POST", "OPTIONS")
protected.Handle("/tags/{id}", http.HandlerFunc(UpdateTagHandler)).Methods("PUT", "OPTIONS")
protected.Handle("/tags/{id}", http.HandlerFunc(DeleteTagHandler)).Methods("DELETE", "OPTIONS")
protected.Handle("/tags/{id}/items", http.HandlerFunc(GetItemsByTagHandler)).Methods("GET", "OPTIONS")
// Item-Tag relationship routes
protected.Handle("/items/{id}/tags", http.HandlerFunc(AddItemTagsHandler)).Methods("POST", "OPTIONS")
protected.Handle("/items/{id}/tags/{tagId}", http.HandlerFunc(RemoveItemTagHandler)).Methods("DELETE", "OPTIONS")
// Admin routes
adminRouter := protected.PathPrefix("/admin").Subrouter()
setupManagementRoutes(adminRouter)
}
func setupManagementRoutes(router *mux.Router) {
router.Handle("/user", http.HandlerFunc(GetUsersHandler)).Methods("GET", "OPTIONS")
router.Handle("/user", http.HandlerFunc(CreateUserHandler)).Methods("POST", "OPTIONS")
router.Handle("/user/{id}", http.HandlerFunc(GetUserHandler)).Methods("GET", "OPTIONS")
router.Handle("/user/{id}", http.HandlerFunc(DeleteUserHandler)).Methods("DELETE", "OPTIONS")
router.Handle("/db", http.HandlerFunc(BackupDatabaseHandler)).Methods("GET", "OPTIONS")
router.Handle("/db", http.HandlerFunc(RestoreDatabaseHandler)).Methods("POST", "OPTIONS")
}
func setupStaticRoutes(router *mux.Router, staticPath string) {
// Ensure static directory exists
if err := validateStaticDirectory(staticPath); err != nil {
log.Printf("Warning: Static directory validation failed: %v", err)
}
fileServer := http.FileServer(http.Dir(staticPath))
// Only serve static files for paths that don't start with /api
router.PathPrefix("/").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := GetLogger()
// Don't serve static files for API paths
if strings.HasPrefix(r.URL.Path, "/api/") {
http.NotFound(w, r)
return
}
// Log static file request
if logger != nil {
logger.Debug("Static file request: %s", r.URL.Path)
}
// Check if the file exists
path := filepath.Join(staticPath, r.URL.Path)
_, err := os.Stat(path)
// If file doesn't exist, serve index.html for SPA routing
if os.IsNotExist(err) {
if logger != nil {
logger.Debug("File not found, serving index.html: %s", path)
}
http.ServeFile(w, r, filepath.Join(staticPath, "index.html"))
return
}
// Strip the leading "/" before serving
http.StripPrefix("/", fileServer).ServeHTTP(w, r)
}))
}
func createCustomStaticHandler(staticPath string) http.HandlerFunc {
fileServer := http.FileServer(http.Dir(staticPath))
return func(w http.ResponseWriter, r *http.Request) {
logger := GetLogger()
// Log static file request
if logger != nil {
logger.Debug("Static file request: %s", r.URL.Path)
}
// Check if the file exists
path := filepath.Join(staticPath, r.URL.Path)
_, err := os.Stat(path)
// If file doesn't exist, serve index.html for SPA routing
if os.IsNotExist(err) {
if logger != nil {
logger.Debug("File not found, serving index.html: %s", path)
}
http.ServeFile(w, r, filepath.Join(staticPath, "index.html"))
return
}
fileServer.ServeHTTP(w, r)
}
}
func loadAndValidateConfig() (*Config, error) {
configFile := os.Getenv("BOXES_API_CONFIG")
if configFile == "" {
configFile = "config.yaml" // Default config path
}
var err error
config, err := LoadConfig(configFile)
if err != nil {
return nil, fmt.Errorf("failed to load config: %v", err)
}
// Set global variables
jwtSecretBytes := []byte(config.JWTSecret)
JWTSecret = &jwtSecretBytes
ImageStorage = &config.ImageStorageDir
DatabasePath = &config.DatabasePath
return config, nil
}
func setupLogging(logLevel string) error {
logger := GetLogger()
if logger == nil {
return fmt.Errorf("logger not initialized")
}
if logLevel != "" {
if err := logger.SetLogLevel(logLevel); err != nil {
return fmt.Errorf("failed to set log level: %v", err)
}
}
return nil
}
func logConfigDetails(config *Config) {
logger := GetLogger()
if logger == nil {
return
}
logger.Info("Configuration loaded:")
logger.Info("Database Path: %s", config.DatabasePath)
logger.Info("Image Storage Dir: %s", config.ImageStorageDir)
logger.Info("Log File: %s", config.LogFile)
logger.Info("Log Level: %s", config.LogLevel)
logger.Info("Listening Port: %d", config.ListeningPort)
logger.Info("Allowed Origins: %s", config.AllowedOrigins)
logger.Info("Static Files Dir: %s", config.StaticFilesDir)
}
func connectToDatabase(config *Config) (*gorm.DB, error) {
if config == nil {
return nil, fmt.Errorf("config is nil")
}
return ConnectDB(config.DatabasePath)
}
func createCORSHandler(allowedOrigins string) func(http.Handler) http.Handler {
corsOpts := cors.Options{
AllowedOrigins: []string{allowedOrigins},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type"},
ExposedHeaders: []string{"Content-Length"},
AllowCredentials: true,
}
// If allowedOrigins is "*", allow all origins
if allowedOrigins == "*" {
corsOpts.AllowedOrigins = []string{"*"}
}
return cors.New(corsOpts).Handler
}
func startServer(port int, handler http.Handler) {
logger := GetLogger()
addr := fmt.Sprintf(":%d", port)
if logger != nil {
logger.Info("Server starting on port %d", port)
}
if err := http.ListenAndServe(addr, handler); err != nil {
if logger != nil {
logger.Error("Server failed to start: %v", err)
}
os.Exit(1)
}
}
func validateStaticDirectory(path string) error {
if info, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("static directory does not exist: %s", path)
}
return fmt.Errorf("error accessing static directory: %v", err)
} else if !info.IsDir() {
return fmt.Errorf("static path is not a directory: %s", path)
}
return nil
}
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := GetLogger()
username := "anonymous"
if user, ok := r.Context().Value(userKey).(string); ok {
username = user
}
rw := &responseWriter{w, http.StatusOK}
next.ServeHTTP(rw, r)
if logger != nil {
logger.HTTPRequest(r.Method, r.URL.Path, username, rw.statusCode)
}
})
} }

12
podman_run.bash Normal file
View File

@ -0,0 +1,12 @@
podman run -d \
--name boxes-api \
-p 8080:8080 \
-e BOXES_API_CONFIG=/app/config/config.yaml \
-v ./data:/app/data \
-v ./images:/app/images \
-v ./config:/app/config \
-v ./build:/app/build \
--network boxes_app-network \
localhost/boxes-api:latest

39
scripts/api.bash Normal file
View File

@ -0,0 +1,39 @@
#!/bin/bash
# API base URL
API_BASE_URL="http://localhost:8080/api/v1"
# Login credentials
USERNAME="boxuser"
PASSWORD="boxuser"
# Function to make an authenticated request
function authenticated_request() {
local method=$1
local endpoint=$2
local data=$3
# Get a new JWT token
TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \
"$API_BASE_URL/login" | jq -r '.token')
# Make the authenticated request
curl -s -X $method -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$data" \
"$API_BASE_URL$endpoint"
}
# Get all boxes
response=$(authenticated_request "GET" "/tags/1/items" "")
echo $response
# Check if the request was successful
if [[ $? -eq 0 ]]; then
# Pretty print the boxes using jq
echo "$response" | jq .
else
echo "Error: Failed to get boxes."
fi

19
scripts/backup_db.bash Normal file
View File

@ -0,0 +1,19 @@
#!/bin/bash
# API base URL
API_BASE_URL="http://localhost:8080"
# Login credentials
USERNAME="boxuser"
PASSWORD="boxuser"
# Get a new JWT token
TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \
"$API_BASE_URL/login" | jq -r '.token')
curl -X GET \
$API_BASE_URL/admin/db \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--output ./test.db

View File

@ -0,0 +1,19 @@
#!/binb/ash
# Set the password you want to use
PASSWORD="boxuser"
<<<<<<< Updated upstream
DATABASE="/Users/stwhite/CODE/Boxes-App/boxes-api/data/boxes.db"
=======
DATABASE="../data/boxes.db"
>>>>>>> Stashed changes
# Use htpasswd to create a bcrypt hash of the password
HASHED_PASSWORD=$(htpasswd -nbBC 10 "" "$PASSWORD" | tr -d ':\n')
# Create the user in the SQLite database
<<<<<<< Updated upstream
sqlite3 $DATABASE "INSERT INTO users (username, password, email) VALUES ('boxuser', '$HASHED_PASSWORD','boxuser@r8z.us')"
=======
sqlite3 $DATABASE "INSERT INTO users (username, password, email) VALUES ('boxuser', '$HASHED_PASSWORD','boxuser@r8z.us');"
>>>>>>> Stashed changes

23
scripts/deleteuser.bash Normal file
View File

@ -0,0 +1,23 @@
#!/bin/bash
# API base URL
API_BASE_URL="http://localhost:8080"
# Login credentials
USERNAME="boxuser"
PASSWORD="boxuser"
JSON_PAYLOAD='{
"username": "testuser",
"password": "testuser"
}'
# Get a new JWT token
TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \
"$API_BASE_URL/login" | jq -r '.token')
curl -X DELETE \
$API_BASE_URL/admin/user/2 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \

70
scripts/gem_script.bash Executable file
View File

@ -0,0 +1,70 @@
#!/bin/bash
# API base URL
API_BASE_URL="http://localhost:8080/api/v1"
# Login credentials
USERNAME="boxuser"
PASSWORD="boxuser"
# Usage message
usage() {
echo "Usage: $0 -m <method> -e <endpoint> [-i <id>] [-d <data>]"
echo " -m <method> HTTP method (GET, POST, PUT, DELETE)"
echo " -e <endpoint> API endpoint (e.g., /tags/1/items)"
echo " -i <id> Optional element ID (appended to endpoint)"
echo " -d <data> Optional data (for POST/PUT requests)"
exit 1
}
# Parse command line options
while getopts ":m:e:i:d:" opt; do
case $opt in
m) method="$OPTARG" ;;
e) endpoint="$OPTARG" ;;
i) id="$OPTARG" ;;
d) data="$OPTARG" ;;
\?) usage ;;
esac
done
# Check if required options are provided
if [[ -z "$method" || -z "$endpoint" ]]; then
usage
fi
# Construct the URL
url="$API_BASE_URL$endpoint"
if [[ -n "$id" ]]; then
url="$url/$id"
fi
# Function to make an authenticated request
function authenticated_request() {
local method=$1
local url=$2
local data=$3
# Get a new JWT token
TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \
"$API_BASE_URL/login" | jq -r '.token')
# Make the authenticated request
curl -s -X "$method" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$data" \
"$url"
}
# Make the API request
response=$(authenticated_request "$method" "$url" "$data")
echo "Raw response: $response"
# Check if the request was successful
if [[ $? -eq 0 ]]; then
# Pretty print the response using jq
echo "$response" | jq .
else
echo "Error: Failed to make API request."
fi

31
scripts/get_token.bash Normal file
View File

@ -0,0 +1,31 @@
#!/bin/bash
API_BASE_URL="http://localhost:8080/api/v1" # Adjusted to include /api/v1
USERNAME="boxuser"
PASSWORD="boxuser"
echo "Sending request to: $API_BASE_URL/login"
echo "Username: $USERNAME"
echo "Password: $PASSWORD"
response=$(curl -vi -w "\n%{http_code}" -X POST -H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \
"$API_BASE_URL/login")
http_status=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
echo "HTTP Status: $http_status"
echo "Response body: $body"
if [ "$http_status" -eq 200 ]; then
TOKEN=$(echo "$body" | jq -r '.token // empty')
if [ -n "$TOKEN" ]; then
echo "Token obtained successfully:"
echo "$TOKEN"
else
echo "Failed to extract token from response."
fi
else
echo "Failed to obtain token. Server returned status $http_status"
fi

View File

@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# API base URL # API base URL
API_BASE_URL="http://10.0.0.66:8080" API_BASE_URL="http://localhost:8080/api/v1"
# Login credentials # Login credentials
USERNAME="boxuser" USERNAME="boxuser"

View File

@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# API base URL # API base URL
API_BASE_URL="http://localhost:8080" API_BASE_URL="http://localhost:8080/api/v1"
# Login credentials # Login credentials
USERNAME="boxuser" USERNAME="boxuser"

View File

@ -0,0 +1,39 @@
#!/bin/bash
# API base URL
API_BASE_URL="http://localhost:8080/api/v1"
# Login credentials
USERNAME="boxuser"
PASSWORD="boxuser"
# Function to make an authenticated request
function authenticated_request() {
local method=$1
local endpoint=$2
local data=$3
# Get a new JWT token
TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \
"$API_BASE_URL/login" | jq -r '.token')
# Make the authenticated request
curl -s -X $method -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$data" \
"$API_BASE_URL$endpoint"
}
# Get all boxes
response=$(authenticated_request "GET" "/tags/1/items" "")
echo $response
# Check if the request was successful
if [[ $? -eq 0 ]]; then
# Pretty print the boxes using jq
echo "$response" | jq .
else
echo "Error: Failed to get boxes."
fi

39
scripts/gettags.bash Normal file
View File

@ -0,0 +1,39 @@
#!/bin/bash
# API base URL
API_BASE_URL="http://localhost:8080/api/v1"
# Login credentials
USERNAME="boxuser"
PASSWORD="boxuser"
# Function to make an authenticated request
function authenticated_request() {
local method=$1
local endpoint=$2
local data=$3
# Get a new JWT token
TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \
"$API_BASE_URL/login" | jq -r '.token')
# Make the authenticated request
curl -s -X $method -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$data" \
"$API_BASE_URL$endpoint"
}
# Get all boxes
response=$(authenticated_request "GET" "/tags?include_items=true" "")
echo $response
# Check if the request was successful
if [[ $? -eq 0 ]]; then
# Pretty print the boxes using jq
echo "$response" | jq .
else
echo "Error: Failed to get boxes."
fi

19
scripts/getusers.bash Normal file
View File

@ -0,0 +1,19 @@
#!/bin/bash
# API base URL
API_BASE_URL="http://localhost:8080/api/v1/"
# Login credentials
USERNAME="boxuser"
PASSWORD="boxuser"
# Get a new JWT token
TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \
"$API_BASE_URL/login" | jq -r '.token')
echo $TOKEN
curl -X GET \
$API_BASE_URL/admin/user \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json"

24
scripts/makeuser.bash Normal file
View File

@ -0,0 +1,24 @@
#!/bin/bash
# API base URL
API_BASE_URL="http://localhost:8080"
# Login credentials
USERNAME="boxuser"
PASSWORD="boxuser"
JSON_PAYLOAD='{
"username": "testuser",
"password": "testuser"
}'
# Get a new JWT token
TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \
"$API_BASE_URL/login" | jq -r '.token')
curl -X POST \
$API_BASE_URL/admin/user \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$JSON_PAYLOAD"

18
scripts/restore_db.bash Normal file
View File

@ -0,0 +1,18 @@
#!/bin/bash
# API base URL
API_BASE_URL="http://localhost:8080"
# Login credentials
USERNAME="boxuser"
PASSWORD="boxuser"
# Get a new JWT token
TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \
"$API_BASE_URL/login" | jq -r '.token')
curl -X POST \
$API_BASE_URL/admin/db \
-H "Authorization: Bearer $TOKEN" \
-F "database=@./test.db"

319
tags_handlers.go Normal file
View File

@ -0,0 +1,319 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gorilla/mux"
)
// TagRequest represents the request body for tag operations
type TagRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
}
// GetTagsHandler returns all tags
func GetTagsHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
loadItems := r.URL.Query().Get("include_items") == "true"
var tags []Tag
query := db.Model(&Tag{})
// Only preload items if specifically requested
if loadItems {
query = query.Preload("Items")
}
if err := query.Find(&tags).Error; err != nil {
log.Error("Failed to fetch tags: %v", err)
http.Error(w, "Failed to fetch tags", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(tags)
}
// CreateTagHandler creates a new tag
func CreateTagHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
var req TagRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Error("Failed to decode tag creation request: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate tag name
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
http.Error(w, "Tag name is required", http.StatusBadRequest)
return
}
// Check for duplicate tag name
var existingTag Tag
if err := db.Where("name = ?", req.Name).First(&existingTag).Error; err == nil {
http.Error(w, "Tag name already exists", http.StatusConflict)
return
}
tag := Tag{
Name: req.Name,
Description: req.Description,
Color: req.Color,
}
if err := db.Create(&tag).Error; err != nil {
log.Error("Failed to create tag: %v", err)
http.Error(w, "Failed to create tag", http.StatusInternalServerError)
return
}
log.DatabaseAction("create", fmt.Sprintf("Created tag: %s", tag.Name))
json.NewEncoder(w).Encode(tag)
}
// UpdateTagHandler updates an existing tag
func UpdateTagHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
vars := mux.Vars(r)
id := vars["id"]
var tag Tag
if err := db.First(&tag, id).Error; err != nil {
http.Error(w, "Tag not found", http.StatusNotFound)
return
}
var req TagRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Check for duplicate name if name is being changed
if req.Name != tag.Name {
var existingTag Tag
if err := db.Where("name = ? AND id != ?", req.Name, id).First(&existingTag).Error; err == nil {
http.Error(w, "Tag name already exists", http.StatusConflict)
return
}
}
tag.Name = req.Name
tag.Description = req.Description
tag.Color = req.Color
if err := db.Save(&tag).Error; err != nil {
log.Error("Failed to update tag: %v", err)
http.Error(w, "Failed to update tag", http.StatusInternalServerError)
return
}
log.DatabaseAction("update", fmt.Sprintf("Updated tag: %s", tag.Name))
json.NewEncoder(w).Encode(tag)
}
// DeleteTagHandler deletes a tag
func DeleteTagHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
vars := mux.Vars(r)
id := vars["id"]
// Begin transaction
tx := db.Begin()
// Remove tag associations
if err := tx.Table("item_tags").Where("tag_id = ?", id).Delete(ItemTag{}).Error; err != nil {
tx.Rollback()
log.Error("Failed to remove tag associations: %v", err)
http.Error(w, "Failed to delete tag", http.StatusInternalServerError)
return
}
// Delete the tag
if err := tx.Delete(&Tag{}, id).Error; err != nil {
tx.Rollback()
log.Error("Failed to delete tag: %v", err)
http.Error(w, "Failed to delete tag", http.StatusInternalServerError)
return
}
// Commit transaction
if err := tx.Commit().Error; err != nil {
log.Error("Failed to commit tag deletion: %v", err)
http.Error(w, "Failed to delete tag", http.StatusInternalServerError)
return
}
log.DatabaseAction("delete", fmt.Sprintf("Deleted tag ID: %s", id))
w.WriteHeader(http.StatusNoContent)
}
// AddItemTagsHandler adds tags to an item
// AddItemTagsHandler adds tags to an item
func AddItemTagsHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
vars := mux.Vars(r)
itemID := vars["id"]
log.Info("Attempting to add tags to item %s", itemID)
var item Item
if err := db.First(&item, itemID).Error; err != nil {
log.Error("Failed to find item %s: %v", itemID, err)
http.Error(w, "Item not found", http.StatusNotFound)
return
}
var tagIDs []uint
if err := json.NewDecoder(r.Body).Decode(&tagIDs); err != nil {
log.Error("Failed to decode tag IDs from request: %v", err)
http.Error(w, "Invalid request body - expecting array of tag IDs", http.StatusBadRequest)
return
}
log.Info("Received request to add tags %v to item %s", tagIDs, itemID)
// Begin transaction
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// First, fetch all the tags we want to add
var tags []Tag
if err := tx.Where("id IN (?)", tagIDs).Find(&tags).Error; err != nil {
tx.Rollback()
log.Error("Failed to fetch tags: %v", err)
http.Error(w, "Failed to fetch tags", http.StatusInternalServerError)
return
}
// Verify we found all requested tags
if len(tags) != len(tagIDs) {
tx.Rollback()
log.Error("Not all tags were found. Requested: %d, Found: %d", len(tagIDs), len(tags))
http.Error(w, "One or more tags not found", http.StatusBadRequest)
return
}
// Direct SQL approach to insert associations
for _, tag := range tags {
// Check if association already exists using count
var count int64
err := tx.Table("item_tags").
Where("item_id = ? AND tag_id = ?", item.ID, tag.ID).
Count(&count).Error
if err != nil {
tx.Rollback()
log.Error("Failed to check existing association: %v", err)
http.Error(w, "Failed to check existing association", http.StatusInternalServerError)
return
}
if count == 0 {
// Insert new association
err = tx.Exec("INSERT INTO item_tags (item_id, tag_id) VALUES (?, ?)",
item.ID, tag.ID).Error
if err != nil {
tx.Rollback()
log.Error("Failed to insert tag association: %v", err)
http.Error(w, "Failed to add tag association", http.StatusInternalServerError)
return
}
log.Info("Successfully added tag %d to item %s", tag.ID, itemID)
} else {
log.Info("Tag %d already associated with item %s", tag.ID, itemID)
}
}
// Commit transaction
if err := tx.Commit().Error; err != nil {
log.Error("Failed to commit tag additions: %v", err)
http.Error(w, "Failed to add tags", http.StatusInternalServerError)
return
}
// Fetch updated item with tags
var updatedItem Item
if err := db.Preload("Tags").First(&updatedItem, itemID).Error; err != nil {
log.Error("Failed to fetch updated item: %v", err)
http.Error(w, "Failed to fetch updated item", http.StatusInternalServerError)
return
}
log.DatabaseAction("update", fmt.Sprintf("Added tags %v to item %s", tagIDs, itemID))
json.NewEncoder(w).Encode(updatedItem)
}
// RemoveItemTagHandler removes a tag from an item
func RemoveItemTagHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
vars := mux.Vars(r)
itemID := vars["id"]
tagID := vars["tagId"]
log.Info("Attempting to remove tag %s from item %s", tagID, itemID)
// Begin transaction
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// Delete the association directly
if err := tx.Exec("DELETE FROM item_tags WHERE item_id = ? AND tag_id = ?",
itemID, tagID).Error; err != nil {
tx.Rollback()
log.Error("Failed to remove tag %s from item %s: %v", tagID, itemID, err)
http.Error(w, "Failed to remove tag", http.StatusInternalServerError)
return
}
// Commit transaction
if err := tx.Commit().Error; err != nil {
log.Error("Failed to commit tag removal: %v", err)
http.Error(w, "Failed to remove tag", http.StatusInternalServerError)
return
}
// Fetch updated item with remaining tags
var updatedItem Item
if err := db.Preload("Tags").First(&updatedItem, itemID).Error; err != nil {
log.Error("Failed to fetch updated item: %v", err)
http.Error(w, "Failed to fetch updated item", http.StatusInternalServerError)
return
}
log.DatabaseAction("update", fmt.Sprintf("Removed tag %s from item %s", tagID, itemID))
json.NewEncoder(w).Encode(updatedItem)
}
// GetItemsByTagHandler returns all items with a specific tag
func GetItemsByTagHandler(w http.ResponseWriter, r *http.Request) {
log := GetLogger()
vars := mux.Vars(r)
tagID := vars["id"]
var items []Item
if err := db.Joins("JOIN item_tags ON items.id = item_tags.item_id").
Where("item_tags.tag_id = ?", tagID).
Preload("Tags"). // Always preload tags for items
Find(&items).Error; err != nil {
log.Error("Failed to fetch items by tag: %v", err)
http.Error(w, "Failed to fetch items", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(items)
}