Initial implementation of inventree
A small IT asset inventory system built around a nested folder tree, matching the project brief: Go + html/template + Bootstrap 5 + vanilla JS, SQLite storage (WAL, FKs, migrations), embedded templates/static assets, single compiled binary. - AJAX-driven inventory tree: lazy-loaded folder expansion with per-user persisted expand state, drag-and-drop move for folders/assets, safe delete-with-relocate-or-cascade confirmation, search with breadcrumbs. - Assets: CRUD, duplicate, move, auto-generated IT-000001-style IDs, CSV import (with optional folder auto-create) and export. - Admin-defined item types with custom fields (text/long_text/number/date/ boolean/dropdown/url/ip_address). - Configurable statuses, session-based auth (bcrypt, secure cookies), admin/user roles, user management. - CLI: serve, version, database migrate, database backup; /healthz; graceful shutdown; Dockerfile/docker-compose/systemd unit; Go test suite.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
// Package web implements the HTTP server: routing, session middleware,
|
||||
// template rendering, and all page/API handlers for inventree.
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"inventree/internal/assets"
|
||||
"inventree/internal/auth"
|
||||
"inventree/internal/folders"
|
||||
"inventree/internal/itemtypes"
|
||||
"inventree/internal/settings"
|
||||
"inventree/internal/statuses"
|
||||
"inventree/internal/users"
|
||||
)
|
||||
|
||||
// App holds every dependency HTTP handlers need: data stores, templates, and
|
||||
// server-wide configuration.
|
||||
type App struct {
|
||||
DB *sql.DB
|
||||
|
||||
Auth *auth.Store
|
||||
Users *users.Store
|
||||
Folders *folders.Store
|
||||
Assets *assets.Store
|
||||
ItemTypes *itemtypes.Store
|
||||
Statuses *statuses.Store
|
||||
Settings *settings.Store
|
||||
|
||||
templates *templateSet
|
||||
Log *slog.Logger
|
||||
|
||||
// SecureCookies controls the Secure flag on the session cookie; set
|
||||
// true when serving over HTTPS.
|
||||
SecureCookies bool
|
||||
}
|
||||
|
||||
// NewApp wires all stores together from a shared database handle.
|
||||
func NewApp(db *sql.DB, log *slog.Logger, secureCookies bool) (*App, error) {
|
||||
userStore := users.NewStore(db)
|
||||
folderStore := folders.NewStore(db)
|
||||
settingsStore := settings.NewStore(db)
|
||||
|
||||
app := &App{
|
||||
DB: db,
|
||||
Users: userStore,
|
||||
Folders: folderStore,
|
||||
ItemTypes: itemtypes.NewStore(db),
|
||||
Statuses: statuses.NewStore(db),
|
||||
Settings: settingsStore,
|
||||
Assets: assets.NewStore(db, settingsStore, folderStore),
|
||||
Auth: auth.NewStore(db, userStore),
|
||||
Log: log,
|
||||
SecureCookies: secureCookies,
|
||||
}
|
||||
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app.templates = tmpl
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// StartSessionJanitor periodically removes expired sessions until ctx (via
|
||||
// the returned stop channel) signals shutdown.
|
||||
func (a *App) StartSessionJanitor(stop <-chan struct{}) {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
go func() {
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := a.Auth.CleanExpired(context.Background()); err != nil {
|
||||
a.Log.Warn("session cleanup failed", "error", err)
|
||||
}
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||
http.Error(w, msg, status)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package web
|
||||
|
||||
import "context"
|
||||
|
||||
// FolderOption is a flattened, indented folder choice for <select> dropdowns
|
||||
// (asset create/edit, move-to pickers).
|
||||
type FolderOption struct {
|
||||
ID int64
|
||||
Path string
|
||||
Depth int
|
||||
}
|
||||
|
||||
// folderOptions returns every folder as a flat, depth-first ordered list
|
||||
// with indentable depth, suitable for a folder-picker <select>.
|
||||
func (a *App) folderOptions(ctx context.Context) ([]FolderOption, error) {
|
||||
all, err := a.Folders.All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byParent := map[int64][]struct {
|
||||
ID int64
|
||||
Name string
|
||||
}{}
|
||||
var top []struct {
|
||||
ID int64
|
||||
Name string
|
||||
}
|
||||
for _, f := range all {
|
||||
item := struct {
|
||||
ID int64
|
||||
Name string
|
||||
}{f.ID, f.Name}
|
||||
if f.ParentID == nil {
|
||||
top = append(top, item)
|
||||
} else {
|
||||
byParent[*f.ParentID] = append(byParent[*f.ParentID], item)
|
||||
}
|
||||
}
|
||||
|
||||
var out []FolderOption
|
||||
var walk func(id int64, name, prefix string, depth int)
|
||||
walk = func(id int64, name, prefix string, depth int) {
|
||||
path := name
|
||||
if prefix != "" {
|
||||
path = prefix + " / " + name
|
||||
}
|
||||
out = append(out, FolderOption{ID: id, Path: path, Depth: depth})
|
||||
for _, child := range byParent[id] {
|
||||
walk(child.ID, child.Name, path, depth+1)
|
||||
}
|
||||
}
|
||||
for _, t := range top {
|
||||
walk(t.ID, t.Name, "", 0)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
type assetRequest struct {
|
||||
AssetTag string `json:"asset_id"`
|
||||
Name string `json:"name"`
|
||||
ItemTypeID int64 `json:"item_type_id"`
|
||||
FolderID *int64 `json:"folder_id"`
|
||||
StatusID int64 `json:"status_id"`
|
||||
AssignedUserID *int64 `json:"assigned_user_id"`
|
||||
Notes string `json:"notes"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
}
|
||||
|
||||
func (req assetRequest) fieldValues() map[int64]string {
|
||||
out := make(map[int64]string, len(req.Fields))
|
||||
for k, v := range req.Fields {
|
||||
id, err := strconv.ParseInt(k, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[id] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) handleAssetCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var req assetRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
asset := &models.Asset{
|
||||
AssetTag: req.AssetTag, Name: req.Name, ItemTypeID: req.ItemTypeID, FolderID: req.FolderID,
|
||||
StatusID: req.StatusID, AssignedUserID: req.AssignedUserID, Notes: req.Notes,
|
||||
}
|
||||
if err := a.Assets.Create(r.Context(), asset, req.fieldValues()); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{"id": asset.ID, "asset_id": asset.AssetTag, "folder_id": asset.FolderID})
|
||||
}
|
||||
|
||||
func (a *App) handleAssetUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
var req assetRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
existing, err := a.Assets.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
asset := &models.Asset{
|
||||
ID: id, AssetTag: req.AssetTag, Name: req.Name, ItemTypeID: existing.ItemTypeID, FolderID: req.FolderID,
|
||||
StatusID: req.StatusID, AssignedUserID: req.AssignedUserID, Notes: req.Notes,
|
||||
}
|
||||
if err := a.Assets.Update(r.Context(), asset, req.fieldValues()); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{"id": asset.ID, "asset_id": asset.AssetTag, "folder_id": asset.FolderID})
|
||||
}
|
||||
|
||||
func (a *App) handleAssetDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
asset, err := a.Assets.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
if err := a.Assets.Delete(r.Context(), id); err != nil {
|
||||
jsonErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{"id": id, "folder_id": asset.FolderID})
|
||||
}
|
||||
|
||||
func (a *App) handleAssetDuplicate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
copyAsset, err := a.Assets.Duplicate(r.Context(), id)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{"id": copyAsset.ID, "asset_id": copyAsset.AssetTag, "folder_id": copyAsset.FolderID})
|
||||
}
|
||||
|
||||
type assetMoveRequest struct {
|
||||
FolderID *int64 `json:"folder_id"`
|
||||
}
|
||||
|
||||
func (a *App) handleAssetMove(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
var req assetMoveRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := a.Assets.Move(r.Context(), id, req.FolderID); err != nil {
|
||||
jsonErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{"id": id, "folder_id": req.FolderID})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"inventree/internal/auth"
|
||||
)
|
||||
|
||||
type loginPageData struct {
|
||||
Error string
|
||||
Next string
|
||||
}
|
||||
|
||||
func (a *App) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := auth.UserFromContext(r.Context()); ok {
|
||||
http.Redirect(w, r, "/inventory", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.Render(w, http.StatusOK, "login", loginPageData{Next: r.URL.Query().Get("next")})
|
||||
}
|
||||
|
||||
func (a *App) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
a.Render(w, http.StatusBadRequest, "login", loginPageData{Error: "Invalid form submission."})
|
||||
return
|
||||
}
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
next := r.FormValue("next")
|
||||
|
||||
u, err := a.Users.Authenticate(r.Context(), username, password)
|
||||
if err != nil {
|
||||
a.Render(w, http.StatusUnauthorized, "login", loginPageData{Error: "Invalid username or password.", Next: next})
|
||||
return
|
||||
}
|
||||
|
||||
token, expires, err := a.Auth.CreateSession(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
a.Render(w, http.StatusInternalServerError, "login", loginPageData{Error: "Could not start a session. Please try again."})
|
||||
return
|
||||
}
|
||||
auth.SetCookie(w, token, expires, a.SecureCookies)
|
||||
|
||||
dest := "/inventory"
|
||||
if next != "" {
|
||||
dest = next
|
||||
}
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(auth.CookieName); err == nil {
|
||||
_ = a.Auth.DestroySession(r.Context(), cookie.Value)
|
||||
}
|
||||
auth.ClearCookie(w, a.SecureCookies)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"inventree/internal/assets"
|
||||
)
|
||||
|
||||
func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
filter := assets.ListFilter{
|
||||
ItemTypeID: queryInt64(r, "item_type_id"),
|
||||
StatusID: queryInt64(r, "status_id"),
|
||||
AssignedUserID: queryInt64(r, "assigned_user_id"),
|
||||
Query: r.URL.Query().Get("q"),
|
||||
}
|
||||
if folderID, err := queryOptionalID(r, "folder_id"); err == nil && folderID != nil {
|
||||
filter.FolderID = folderID
|
||||
filter.FolderSet = true
|
||||
filter.IncludeSub = r.URL.Query().Get("recursive") == "1"
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("inventree-assets-%s.csv", time.Now().Format("2006-01-02"))
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||
if err := a.Assets.Export(ctx, w, filter); err != nil {
|
||||
a.Log.Error("csv export failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
createMissing := r.FormValue("create_missing_folders") == "1"
|
||||
|
||||
itemTypes, err := a.ItemTypes.List(ctx, true)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
statusList, err := a.Statuses.List(ctx)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
userList, err := a.Users.List(ctx)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := a.Assets.Import(ctx, file, assets.ImportOptions{CreateMissingFolders: createMissing}, itemTypes, statusList, userList)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, result)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"inventree/internal/folders"
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
func (a *App) handleFolderGet(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
f, err := a.Folders.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, f)
|
||||
}
|
||||
|
||||
type folderCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
ParentID *int64 `json:"parent_id"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
func (a *App) handleFolderCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var req folderCreateRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
f := &models.Folder{Name: req.Name, ParentID: req.ParentID, Description: req.Description, Icon: req.Icon, SortOrder: req.SortOrder}
|
||||
if err := a.Folders.Create(r.Context(), f); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, f)
|
||||
}
|
||||
|
||||
type folderUpdateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
func (a *App) handleFolderUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
var req folderUpdateRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
f, err := a.Folders.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
f.Name, f.Description, f.Icon, f.SortOrder = req.Name, req.Description, req.Icon, req.SortOrder
|
||||
if err := a.Folders.Update(r.Context(), f); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, f)
|
||||
}
|
||||
|
||||
type folderMoveRequest struct {
|
||||
ParentID *int64 `json:"parent_id"`
|
||||
}
|
||||
|
||||
func (a *App) handleFolderMove(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
var req folderMoveRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := a.Folders.Move(r.Context(), id, req.ParentID); err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, folders.ErrInvalidParent) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
jsonErr(w, status, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{"id": id, "parent_id": req.ParentID})
|
||||
}
|
||||
|
||||
func (a *App) handleFolderDeleteInfo(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
folderCount, assetCount, err := a.Folders.Counts(ctx, id)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
f, err := a.Folders.Get(ctx, id)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
all, err := a.Folders.All(ctx)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
descendants := map[int64]bool{id: true}
|
||||
changed := true
|
||||
for changed {
|
||||
changed = false
|
||||
for _, cand := range all {
|
||||
if cand.ParentID != nil && descendants[*cand.ParentID] && !descendants[cand.ID] {
|
||||
descendants[cand.ID] = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
byID := map[int64]models.Folder{}
|
||||
for _, cand := range all {
|
||||
byID[cand.ID] = cand
|
||||
}
|
||||
var pathOf func(int64) string
|
||||
pathOf = func(fid int64) string {
|
||||
ff, ok := byID[fid]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if ff.ParentID == nil {
|
||||
return ff.Name
|
||||
}
|
||||
return pathOf(*ff.ParentID) + " / " + ff.Name
|
||||
}
|
||||
|
||||
type dest struct {
|
||||
ID int64 `json:"id"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
destinations := []dest{{ID: 0, Path: "Inventory (top level)"}}
|
||||
for _, cand := range all {
|
||||
if descendants[cand.ID] {
|
||||
continue
|
||||
}
|
||||
destinations = append(destinations, dest{ID: cand.ID, Path: pathOf(cand.ID)})
|
||||
}
|
||||
|
||||
jsonOK(w, map[string]any{
|
||||
"folder_name": f.Name,
|
||||
"folder_count": folderCount,
|
||||
"asset_count": assetCount,
|
||||
"destinations": destinations,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) handleFolderDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
mode := r.URL.Query().Get("mode")
|
||||
switch mode {
|
||||
case "relocate":
|
||||
var dest *int64
|
||||
if raw := r.URL.Query().Get("dest"); raw != "" && raw != "0" {
|
||||
d, err := queryOptionalID(r, "dest")
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
dest = d
|
||||
}
|
||||
if err := a.Folders.DeleteAndRelocate(ctx, id, dest); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
case "cascade":
|
||||
if err := a.Folders.CascadeDelete(ctx, id); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
if err := a.Folders.Delete(ctx, id); err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, folders.ErrNotEmpty) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
jsonErr(w, status, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
jsonOK(w, map[string]any{"id": id})
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"inventree/internal/assets"
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
// InventoryPageData is the top-level data passed to templates/pages/inventory.html.
|
||||
type InventoryPageData struct {
|
||||
User *models.User
|
||||
Tree []models.TreeNode
|
||||
Root *RootPanelData
|
||||
ItemTypes []models.ItemType
|
||||
Statuses []models.Status
|
||||
}
|
||||
|
||||
func (a *App) handleInventoryPage(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
u := currentUser(r)
|
||||
|
||||
expanded, err := a.expandedFolderIDs(ctx, u.ID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
tree, err := a.buildTree(ctx, nil, expanded)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
root, err := a.rootPanelData(ctx)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
itemTypes, err := a.ItemTypes.List(ctx, false)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
statusList, err := a.Statuses.List(ctx)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
a.Render(w, http.StatusOK, "inventory", InventoryPageData{
|
||||
User: u, Tree: tree, Root: root, ItemTypes: itemTypes, Statuses: statusList,
|
||||
})
|
||||
}
|
||||
|
||||
// handleTreeChildren serves one level of the tree lazily for AJAX expand.
|
||||
// When persist=1, the expanded state is remembered for the current user.
|
||||
func (a *App) handleTreeChildren(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
u := currentUser(r)
|
||||
|
||||
parentID, err := queryOptionalID(r, "parent_id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid parent_id")
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("persist") == "1" && parentID != nil {
|
||||
if err := a.persistExpand(ctx, u.ID, *parentID); err != nil {
|
||||
a.Log.Warn("persist expand failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Honor already-persisted expand state for descendants so that
|
||||
// re-fetching a branch (e.g. after a drag-and-drop move) doesn't
|
||||
// visually collapse folders the user had already opened.
|
||||
expanded, err := a.expandedFolderIDs(ctx, u.ID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
nodes, err := a.buildTree(ctx, parentID, expanded)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.RenderFragment(w, http.StatusOK, "tree_children", nodes)
|
||||
}
|
||||
|
||||
func (a *App) handleTreeCollapse(w http.ResponseWriter, r *http.Request) {
|
||||
u := currentUser(r)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
folderID := queryInt64(r, "folder_id")
|
||||
if folderID == 0 {
|
||||
writeErr(w, http.StatusBadRequest, "folder_id is required")
|
||||
return
|
||||
}
|
||||
if err := a.persistCollapse(r.Context(), u.ID, folderID); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (a *App) handleTreeCollapseAll(w http.ResponseWriter, r *http.Request) {
|
||||
u := currentUser(r)
|
||||
if err := a.persistCollapseAll(r.Context(), u.ID); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RootPanelData backs the main panel when nothing is selected: the virtual
|
||||
// "Inventory" root, showing top-level folders, unfiled assets, and the
|
||||
// small dashboard.
|
||||
type RootPanelData struct {
|
||||
Folders []models.Folder
|
||||
UnfiledAssets []models.Asset
|
||||
UnfiledTotal int
|
||||
Stats *assets.DashboardStats
|
||||
}
|
||||
|
||||
func (a *App) rootPanelData(ctx context.Context) (*RootPanelData, error) {
|
||||
folderList, err := a.Folders.Children(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unfiled, total, err := a.Assets.List(ctx, assets.ListFilter{FolderSet: true, FolderID: nil, Page: 1, PageSize: 50})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats, err := a.Assets.Stats(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RootPanelData{Folders: folderList, UnfiledAssets: unfiled, UnfiledTotal: total, Stats: stats}, nil
|
||||
}
|
||||
|
||||
func (a *App) handlePanelRoot(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := a.rootPanelData(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.RenderFragment(w, http.StatusOK, "panel_root", data)
|
||||
}
|
||||
|
||||
// FolderPanelData backs the main panel when a folder is selected.
|
||||
type FolderPanelData struct {
|
||||
Folder *models.Folder
|
||||
Breadcrumb []models.BreadcrumbItem
|
||||
Subfolders []models.Folder
|
||||
Assets []models.Asset
|
||||
Total int
|
||||
Page int
|
||||
PageSize int
|
||||
ItemTypes []models.ItemType
|
||||
Statuses []models.Status
|
||||
FilterType int64
|
||||
FilterStat int64
|
||||
Query string
|
||||
}
|
||||
|
||||
func (a *App) handlePanelFolder(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid folder id")
|
||||
return
|
||||
}
|
||||
folder, err := a.Folders.Get(ctx, id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
breadcrumb, err := a.Folders.Path(ctx, id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
subfolders, err := a.Folders.Children(ctx, &id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
page := queryInt(r, "page", 1)
|
||||
filterType := queryInt64(r, "item_type_id")
|
||||
filterStatus := queryInt64(r, "status_id")
|
||||
query := r.URL.Query().Get("q")
|
||||
|
||||
assetList, total, err := a.Assets.List(ctx, assets.ListFilter{
|
||||
FolderID: &id, FolderSet: true, ItemTypeID: filterType, StatusID: filterStatus,
|
||||
Query: query, Page: page, PageSize: 50,
|
||||
})
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
itemTypes, err := a.ItemTypes.List(ctx, false)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
statusList, err := a.Statuses.List(ctx)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
a.RenderFragment(w, http.StatusOK, "panel_folder", FolderPanelData{
|
||||
Folder: folder, Breadcrumb: breadcrumb, Subfolders: subfolders,
|
||||
Assets: assetList, Total: total, Page: page, PageSize: 50,
|
||||
ItemTypes: itemTypes, Statuses: statusList,
|
||||
FilterType: filterType, FilterStat: filterStatus, Query: query,
|
||||
})
|
||||
}
|
||||
|
||||
// AssetPanelData backs the main panel when an asset is selected (view mode).
|
||||
type AssetPanelData struct {
|
||||
Asset *models.Asset
|
||||
CanEdit bool
|
||||
FolderOptions []FolderOption
|
||||
}
|
||||
|
||||
func (a *App) handlePanelAsset(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid asset id")
|
||||
return
|
||||
}
|
||||
asset, err := a.Assets.Get(ctx, id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
opts, err := a.folderOptions(ctx)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.RenderFragment(w, http.StatusOK, "panel_asset", AssetPanelData{Asset: asset, CanEdit: currentUser(r).IsAdmin(), FolderOptions: opts})
|
||||
}
|
||||
|
||||
// AssetFormData backs both the "new asset" and "edit asset" forms.
|
||||
type AssetFormData struct {
|
||||
Asset *models.Asset // nil when creating
|
||||
FolderID *int64
|
||||
ItemTypes []models.ItemType
|
||||
Statuses []models.Status
|
||||
Users []models.User
|
||||
FolderOptions []FolderOption
|
||||
SelectedIT int64
|
||||
Fields []models.AssetFieldValue
|
||||
}
|
||||
|
||||
func (a *App) handlePanelAssetNew(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
folderID, err := queryOptionalID(r, "folder_id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid folder_id")
|
||||
return
|
||||
}
|
||||
itemTypes, err := a.ItemTypes.List(ctx, false)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
statusList, err := a.Statuses.List(ctx)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
userList, err := a.Users.List(ctx)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
folderOpts, err := a.folderOptions(ctx)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var selectedIT int64
|
||||
var fields []models.AssetFieldValue
|
||||
if len(itemTypes) > 0 {
|
||||
selectedIT = itemTypes[0].ID
|
||||
fields = fieldValuesForCreate(itemTypes[0].Fields)
|
||||
}
|
||||
|
||||
a.RenderFragment(w, http.StatusOK, "asset_form", AssetFormData{
|
||||
FolderID: folderID, ItemTypes: itemTypes, Statuses: statusList, Users: userList, FolderOptions: folderOpts,
|
||||
SelectedIT: selectedIT, Fields: fields,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) handlePanelAssetEdit(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid asset id")
|
||||
return
|
||||
}
|
||||
asset, err := a.Assets.Get(ctx, id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
itemTypes, err := a.ItemTypes.List(ctx, true)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
statusList, err := a.Statuses.List(ctx)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
userList, err := a.Users.List(ctx)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
folderOpts, err := a.folderOptions(ctx)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
a.RenderFragment(w, http.StatusOK, "asset_form", AssetFormData{
|
||||
Asset: asset, ItemTypes: itemTypes, Statuses: statusList, Users: userList, FolderOptions: folderOpts,
|
||||
SelectedIT: asset.ItemTypeID, Fields: asset.CustomFields,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAssetFieldsFragment renders just the dynamic custom-field inputs for
|
||||
// a chosen item type, used when the item type dropdown changes on the
|
||||
// "new asset" form.
|
||||
func (a *App) handleAssetFieldsFragment(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
itemTypeID := queryInt64(r, "item_type_id")
|
||||
it, err := a.ItemTypes.Get(ctx, itemTypeID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
a.RenderFragment(w, http.StatusOK, "asset_fields_only", fieldValuesForCreate(it.Fields))
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
type itemTypesListData struct {
|
||||
User *models.User
|
||||
ItemTypes []models.ItemType
|
||||
}
|
||||
|
||||
func (a *App) handleItemTypesList(w http.ResponseWriter, r *http.Request) {
|
||||
types, err := a.ItemTypes.List(r.Context(), true)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.Render(w, http.StatusOK, "itemtypes_list", itemTypesListData{User: currentUser(r), ItemTypes: types})
|
||||
}
|
||||
|
||||
type itemTypeFormData struct {
|
||||
User *models.User
|
||||
ItemType *models.ItemType
|
||||
FieldTypes []models.FieldType
|
||||
Error string
|
||||
}
|
||||
|
||||
func (a *App) handleItemTypeNewForm(w http.ResponseWriter, r *http.Request) {
|
||||
a.Render(w, http.StatusOK, "itemtypes_form", itemTypeFormData{User: currentUser(r), FieldTypes: models.AllFieldTypes})
|
||||
}
|
||||
|
||||
func (a *App) handleItemTypeCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
it := &models.ItemType{
|
||||
Name: r.FormValue("name"), Description: r.FormValue("description"),
|
||||
Icon: r.FormValue("icon"), Enabled: r.FormValue("enabled") == "on", SortOrder: atoiDefault(r.FormValue("sort_order"), 0),
|
||||
}
|
||||
if err := a.ItemTypes.Create(r.Context(), it); err != nil {
|
||||
a.Render(w, http.StatusBadRequest, "itemtypes_form", itemTypeFormData{User: currentUser(r), ItemType: it, FieldTypes: models.AllFieldTypes, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(it.ID, 10)+"/edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleItemTypeEditForm(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
it, err := a.ItemTypes.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
a.Render(w, http.StatusOK, "itemtypes_form", itemTypeFormData{User: currentUser(r), ItemType: it, FieldTypes: models.AllFieldTypes})
|
||||
}
|
||||
|
||||
func (a *App) handleItemTypeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
it, err := a.ItemTypes.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
it.Name = r.FormValue("name")
|
||||
it.Description = r.FormValue("description")
|
||||
it.Icon = r.FormValue("icon")
|
||||
it.Enabled = r.FormValue("enabled") == "on"
|
||||
it.SortOrder = atoiDefault(r.FormValue("sort_order"), it.SortOrder)
|
||||
if err := a.ItemTypes.Update(r.Context(), it); err != nil {
|
||||
a.Render(w, http.StatusBadRequest, "itemtypes_form", itemTypeFormData{User: currentUser(r), ItemType: it, FieldTypes: models.AllFieldTypes, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleItemTypeDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.ItemTypes.Delete(r.Context(), id); err != nil {
|
||||
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(id, 10)+"/edit?error="+err.Error(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/item-types", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleFieldCreate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
f := &models.CustomFieldDef{
|
||||
ItemTypeID: id, Name: r.FormValue("name"), FieldType: models.FieldType(r.FormValue("field_type")),
|
||||
Required: r.FormValue("required") == "on", DefaultValue: r.FormValue("default_value"),
|
||||
SortOrder: atoiDefault(r.FormValue("sort_order"), 0),
|
||||
}
|
||||
if opts := r.FormValue("options"); opts != "" {
|
||||
f.Options = splitLines(opts)
|
||||
}
|
||||
if err := a.ItemTypes.CreateField(r.Context(), f); err != nil {
|
||||
a.Log.Warn("create field failed", "error", err)
|
||||
}
|
||||
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleFieldUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
itemTypeID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
fieldID, err := pathID(r, "fieldID")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
f, err := a.ItemTypes.GetField(r.Context(), fieldID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
f.Name = r.FormValue("name")
|
||||
f.FieldType = models.FieldType(r.FormValue("field_type"))
|
||||
f.Required = r.FormValue("required") == "on"
|
||||
f.DefaultValue = r.FormValue("default_value")
|
||||
f.SortOrder = atoiDefault(r.FormValue("sort_order"), f.SortOrder)
|
||||
f.Options = nil
|
||||
if opts := r.FormValue("options"); opts != "" {
|
||||
f.Options = splitLines(opts)
|
||||
}
|
||||
if err := a.ItemTypes.UpdateField(r.Context(), f); err != nil {
|
||||
a.Log.Warn("update field failed", "error", err)
|
||||
}
|
||||
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(itemTypeID, 10)+"/edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleFieldDelete(w http.ResponseWriter, r *http.Request) {
|
||||
itemTypeID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
fieldID, err := pathID(r, "fieldID")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.ItemTypes.DeleteField(r.Context(), fieldID); err != nil {
|
||||
a.Log.Warn("delete field failed", "error", err)
|
||||
}
|
||||
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(itemTypeID, 10)+"/edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func atoiDefault(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
type SearchPanelData struct {
|
||||
Query string
|
||||
Results []models.Asset
|
||||
}
|
||||
|
||||
func (a *App) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
results, err := a.Assets.Search(r.Context(), q, 50)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.RenderFragment(w, http.StatusOK, "panel_search", SearchPanelData{Query: q, Results: results})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
type settingsPageData struct {
|
||||
User *models.User
|
||||
Statuses []models.Status
|
||||
SiteName string
|
||||
AssetPrefix string
|
||||
AssetPadding string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (a *App) settingsData(r *http.Request) (settingsPageData, error) {
|
||||
ctx := r.Context()
|
||||
statusList, err := a.Statuses.List(ctx)
|
||||
if err != nil {
|
||||
return settingsPageData{}, err
|
||||
}
|
||||
siteName, err := a.Settings.Get(ctx, "site_name", "inventree")
|
||||
if err != nil {
|
||||
return settingsPageData{}, err
|
||||
}
|
||||
prefix, err := a.Settings.Get(ctx, "asset_id_prefix", "IT-")
|
||||
if err != nil {
|
||||
return settingsPageData{}, err
|
||||
}
|
||||
padding, err := a.Settings.Get(ctx, "asset_id_padding", "6")
|
||||
if err != nil {
|
||||
return settingsPageData{}, err
|
||||
}
|
||||
return settingsPageData{
|
||||
User: currentUser(r), Statuses: statusList, SiteName: siteName, AssetPrefix: prefix, AssetPadding: padding,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.Render(w, http.StatusOK, "settings", data)
|
||||
}
|
||||
|
||||
func (a *App) handleSettingsGeneral(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
_ = a.Settings.Set(ctx, "site_name", r.FormValue("site_name"))
|
||||
_ = a.Settings.Set(ctx, "asset_id_prefix", r.FormValue("asset_id_prefix"))
|
||||
if _, err := strconv.Atoi(r.FormValue("asset_id_padding")); err == nil {
|
||||
_ = a.Settings.Set(ctx, "asset_id_padding", r.FormValue("asset_id_padding"))
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleStatusCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
st := &models.Status{Name: r.FormValue("name"), Color: r.FormValue("color")}
|
||||
if err := a.Statuses.Create(r.Context(), st); err != nil {
|
||||
a.Log.Warn("create status failed", "error", err)
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleStatusUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
st, err := a.Statuses.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
st.Name = r.FormValue("name")
|
||||
st.Color = r.FormValue("color")
|
||||
if err := a.Statuses.Update(r.Context(), st); err != nil {
|
||||
a.Log.Warn("update status failed", "error", err)
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleStatusDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.Statuses.Delete(r.Context(), id); err != nil {
|
||||
a.Log.Warn("delete status failed", "error", err)
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleStatusReorder(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
dir := r.FormValue("dir")
|
||||
|
||||
list, err := a.Statuses.List(ctx)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
idx := -1
|
||||
for i, st := range list {
|
||||
if st.ID == id {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx >= 0 {
|
||||
swapWith := -1
|
||||
if dir == "up" && idx > 0 {
|
||||
swapWith = idx - 1
|
||||
} else if dir == "down" && idx < len(list)-1 {
|
||||
swapWith = idx + 1
|
||||
}
|
||||
if swapWith >= 0 {
|
||||
ids := make([]int64, len(list))
|
||||
for i, st := range list {
|
||||
ids[i] = st.ID
|
||||
}
|
||||
ids[idx], ids[swapWith] = ids[swapWith], ids[idx]
|
||||
if err := a.Statuses.Reorder(ctx, ids); err != nil {
|
||||
a.Log.Warn("reorder statuses failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
type usersListData struct {
|
||||
User *models.User
|
||||
Users []models.User
|
||||
}
|
||||
|
||||
func (a *App) handleUsersList(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := a.Users.List(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.Render(w, http.StatusOK, "users_list", usersListData{User: currentUser(r), Users: list})
|
||||
}
|
||||
|
||||
type userFormData struct {
|
||||
User *models.User
|
||||
EditUser *models.User
|
||||
Error string
|
||||
}
|
||||
|
||||
func (a *App) handleUserNewForm(w http.ResponseWriter, r *http.Request) {
|
||||
a.Render(w, http.StatusOK, "users_form", userFormData{User: currentUser(r)})
|
||||
}
|
||||
|
||||
func (a *App) handleUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
role := models.RoleUser
|
||||
if r.FormValue("role") == "admin" {
|
||||
role = models.RoleAdmin
|
||||
}
|
||||
u := &models.User{Username: r.FormValue("username"), Role: role, Enabled: r.FormValue("enabled") == "on"}
|
||||
password := r.FormValue("password")
|
||||
if password == "" {
|
||||
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: "Password is required."})
|
||||
return
|
||||
}
|
||||
if err := a.Users.Create(r.Context(), u, password); err != nil {
|
||||
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/users", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleUserEditForm(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
u, err := a.Users.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
a.Render(w, http.StatusOK, "users_form", userFormData{User: currentUser(r), EditUser: u})
|
||||
}
|
||||
|
||||
func (a *App) handleUserUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
u, err := a.Users.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
u.Username = r.FormValue("username")
|
||||
u.Role = models.RoleUser
|
||||
if r.FormValue("role") == "admin" {
|
||||
u.Role = models.RoleAdmin
|
||||
}
|
||||
u.Enabled = r.FormValue("enabled") == "on"
|
||||
if err := a.Users.Update(r.Context(), u); err != nil {
|
||||
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/users", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
password := r.FormValue("password")
|
||||
if password != "" {
|
||||
if err := a.Users.SetPassword(r.Context(), id, password); err != nil {
|
||||
a.Log.Warn("set password failed", "error", err)
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/users/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleUserDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.Users.Delete(r.Context(), id); err != nil {
|
||||
a.Log.Warn("delete user failed", "error", err)
|
||||
}
|
||||
http.Redirect(w, r, "/users", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func pathID(r *http.Request, name string) (int64, error) {
|
||||
return strconv.ParseInt(r.PathValue(name), 10, 64)
|
||||
}
|
||||
|
||||
// queryOptionalID parses a query parameter as *int64; an empty or missing
|
||||
// value returns nil (meaning "top level" / "unfiled" in folder/asset
|
||||
// contexts).
|
||||
func queryOptionalID(r *http.Request, name string) (*int64, error) {
|
||||
v := r.URL.Query().Get(name)
|
||||
if v == "" {
|
||||
return nil, nil
|
||||
}
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
func queryInt64(r *http.Request, name string) int64 {
|
||||
v := r.URL.Query().Get(name)
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
id, _ := strconv.ParseInt(v, 10, 64)
|
||||
return id
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, name string, def int) int {
|
||||
v := r.URL.Query().Get(name)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type jsonEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v jsonEnvelope) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func jsonOK(w http.ResponseWriter, data any) {
|
||||
writeJSON(w, http.StatusOK, jsonEnvelope{OK: true, Data: data})
|
||||
}
|
||||
|
||||
func jsonErr(w http.ResponseWriter, status int, err error) {
|
||||
writeJSON(w, status, jsonEnvelope{OK: false, Error: err.Error()})
|
||||
}
|
||||
|
||||
func readJSON(r *http.Request, v any) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(v)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"inventree/internal/auth"
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
// loadSession attaches the current user (if any) to the request context
|
||||
// based on the session cookie. It never blocks the request.
|
||||
func (a *App) loadSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(auth.CookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
u, err := a.Auth.UserForToken(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
r = r.WithContext(auth.WithUser(r.Context(), u))
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// requireLogin redirects unauthenticated page requests to /login, and
|
||||
// returns 401 for AJAX/API requests instead.
|
||||
func (a *App) requireLogin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := auth.UserFromContext(r.Context()); !ok {
|
||||
if isAjax(r) {
|
||||
writeErr(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// requireAdmin additionally requires the current user to hold the admin
|
||||
// role.
|
||||
func (a *App) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return a.requireLogin(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := auth.UserFromContext(r.Context())
|
||||
if !u.IsAdmin() {
|
||||
writeErr(w, http.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func isAjax(r *http.Request) bool {
|
||||
return r.Header.Get("X-Requested-With") == "XMLHttpRequest" || r.Header.Get("HX-Request") != ""
|
||||
}
|
||||
|
||||
func currentUser(r *http.Request) *models.User {
|
||||
u, _ := auth.UserFromContext(r.Context())
|
||||
return u
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
content "inventree/web"
|
||||
)
|
||||
|
||||
// templateFuncs are available to every template (pages and fragments).
|
||||
var templateFuncs = template.FuncMap{
|
||||
"dict": func(pairs ...any) (map[string]any, error) {
|
||||
if len(pairs)%2 != 0 {
|
||||
return nil, fmt.Errorf("dict: odd number of arguments")
|
||||
}
|
||||
m := make(map[string]any, len(pairs)/2)
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
key, ok := pairs[i].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("dict: key %v is not a string", pairs[i])
|
||||
}
|
||||
m[key] = pairs[i+1]
|
||||
}
|
||||
return m, nil
|
||||
},
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"mul": func(a, b int) int { return a * b },
|
||||
"ceilDiv": func(a, b int) int {
|
||||
if b <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (a + b - 1) / b
|
||||
},
|
||||
"seq": func(n int) []int {
|
||||
out := make([]int, n)
|
||||
for i := range out {
|
||||
out[i] = i
|
||||
}
|
||||
return out
|
||||
},
|
||||
"seqFrom1": func(n int) []int {
|
||||
out := make([]int, n)
|
||||
for i := range out {
|
||||
out[i] = i + 1
|
||||
}
|
||||
return out
|
||||
},
|
||||
"fmtDate": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2006-01-02 15:04")
|
||||
},
|
||||
"fmtDateShort": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format("Jan 2, 2006")
|
||||
},
|
||||
"iconOr": func(icon, fallback string) string {
|
||||
if icon == "" {
|
||||
return fallback
|
||||
}
|
||||
return icon
|
||||
},
|
||||
"hasPrefix": strings.HasPrefix,
|
||||
"lower": strings.ToLower,
|
||||
"list": func(items ...string) []string { return items },
|
||||
"deref": func(p *int64) int64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
},
|
||||
}
|
||||
|
||||
// templateSet bundles the shared fragment/partial template collection with
|
||||
// one isolated template set per full page (see loadTemplates for why pages
|
||||
// must be isolated from one another).
|
||||
type templateSet struct {
|
||||
shared *template.Template // layout.html + partials/*.html; used to render AJAX fragments directly
|
||||
pages map[string]*template.Template // page name -> shared clone + that page's templates/pages/<name>.html
|
||||
login *template.Template // standalone minimal layout for the login screen
|
||||
}
|
||||
|
||||
// loadTemplates parses layout.html and every partial once into a shared
|
||||
// template set, then clones that set once per page in templates/pages/ so
|
||||
// each page can define a block named "content" without colliding with the
|
||||
// others (html/template templates sharing one *template.Template must have
|
||||
// unique names, but isolated clones may reuse the same name freely).
|
||||
func loadTemplates() (*templateSet, error) {
|
||||
shared := template.New("root").Funcs(templateFuncs)
|
||||
|
||||
shared, err := shared.ParseFS(content.TemplatesFS, "templates/layout.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse layout: %w", err)
|
||||
}
|
||||
shared, err = parseGlob(shared, "templates/partials")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse partials: %w", err)
|
||||
}
|
||||
|
||||
pageFiles, err := fs.Glob(content.TemplatesFS, "templates/pages/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pages := map[string]*template.Template{}
|
||||
for _, pf := range pageFiles {
|
||||
name := strings.TrimSuffix(path.Base(pf), ".html")
|
||||
clone, err := shared.Clone()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone template set for %s: %w", name, err)
|
||||
}
|
||||
if _, err := clone.ParseFS(content.TemplatesFS, pf); err != nil {
|
||||
return nil, fmt.Errorf("parse page %s: %w", pf, err)
|
||||
}
|
||||
pages[name] = clone
|
||||
}
|
||||
|
||||
login := template.New("login-root").Funcs(templateFuncs)
|
||||
login, err = login.ParseFS(content.TemplatesFS, "templates/login.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse login: %w", err)
|
||||
}
|
||||
|
||||
return &templateSet{shared: shared, pages: pages, login: login}, nil
|
||||
}
|
||||
|
||||
func parseGlob(t *template.Template, dir string) (*template.Template, error) {
|
||||
files, err := fs.Glob(content.TemplatesFS, dir+"/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return t, nil
|
||||
}
|
||||
return t.ParseFS(content.TemplatesFS, files...)
|
||||
}
|
||||
|
||||
// Render executes the named page's "layout" template (which pulls in that
|
||||
// page's "content" block) into the response. Page "login" uses its own
|
||||
// minimal "login" template instead.
|
||||
func (a *App) Render(w http.ResponseWriter, status int, page string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
if page == "login" {
|
||||
w.WriteHeader(status)
|
||||
if err := a.templates.login.ExecuteTemplate(w, "login", data); err != nil {
|
||||
a.Log.Error("template render failed", "page", page, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ts, ok := a.templates.pages[page]
|
||||
if !ok {
|
||||
writeErr(w, http.StatusInternalServerError, "template not found: "+page)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
if err := ts.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
a.Log.Error("template render failed", "page", page, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RenderFragment executes a single named partial (defined under
|
||||
// templates/partials/) with no layout wrapper, for AJAX responses.
|
||||
func (a *App) RenderFragment(w http.ResponseWriter, status int, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := a.templates.shared.ExecuteTemplate(w, name, data); err != nil {
|
||||
a.Log.Error("fragment render failed", "name", name, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
content "inventree/web"
|
||||
)
|
||||
|
||||
// Router builds the complete HTTP handler for inventree: static assets,
|
||||
// authentication, the AJAX-driven inventory tree, and the traditional
|
||||
// full-page admin sections (item types, users, settings).
|
||||
func (a *App) Router() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Static assets (Bootstrap, icons, vanilla JS) and health check are
|
||||
// always public.
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(mustSub(content.StaticFS, "static"))))
|
||||
mux.HandleFunc("GET /healthz", a.handleHealthz)
|
||||
|
||||
// Auth
|
||||
mux.HandleFunc("GET /login", a.handleLoginForm)
|
||||
mux.HandleFunc("POST /login", a.handleLoginSubmit)
|
||||
mux.HandleFunc("POST /logout", a.handleLogout)
|
||||
|
||||
mux.HandleFunc("GET /{$}", a.requireLogin(a.handleRoot))
|
||||
|
||||
// Inventory tree (AJAX fragments)
|
||||
mux.HandleFunc("GET /inventory", a.requireLogin(a.handleInventoryPage))
|
||||
mux.HandleFunc("GET /app/tree/children", a.requireLogin(a.handleTreeChildren))
|
||||
mux.HandleFunc("POST /app/tree/collapse", a.requireLogin(a.handleTreeCollapse))
|
||||
mux.HandleFunc("POST /app/tree/collapse-all", a.requireLogin(a.handleTreeCollapseAll))
|
||||
|
||||
mux.HandleFunc("GET /app/panel/root", a.requireLogin(a.handlePanelRoot))
|
||||
mux.HandleFunc("GET /app/panel/folders/{id}", a.requireLogin(a.handlePanelFolder))
|
||||
mux.HandleFunc("GET /app/panel/assets/{id}", a.requireLogin(a.handlePanelAsset))
|
||||
mux.HandleFunc("GET /app/panel/assets/new", a.requireAdmin(a.handlePanelAssetNew))
|
||||
mux.HandleFunc("GET /app/panel/assets/{id}/edit", a.requireAdmin(a.handlePanelAssetEdit))
|
||||
mux.HandleFunc("GET /app/forms/asset-fields", a.requireLogin(a.handleAssetFieldsFragment))
|
||||
|
||||
mux.HandleFunc("GET /app/search", a.requireLogin(a.handleSearch))
|
||||
|
||||
// Folder mutations (JSON in/out)
|
||||
mux.HandleFunc("GET /app/folders/{id}", a.requireAdmin(a.handleFolderGet))
|
||||
mux.HandleFunc("POST /app/folders", a.requireAdmin(a.handleFolderCreate))
|
||||
mux.HandleFunc("PATCH /app/folders/{id}", a.requireAdmin(a.handleFolderUpdate))
|
||||
mux.HandleFunc("POST /app/folders/{id}/move", a.requireAdmin(a.handleFolderMove))
|
||||
mux.HandleFunc("GET /app/folders/{id}/delete-info", a.requireAdmin(a.handleFolderDeleteInfo))
|
||||
mux.HandleFunc("DELETE /app/folders/{id}", a.requireAdmin(a.handleFolderDelete))
|
||||
|
||||
// Asset mutations (JSON in/out)
|
||||
mux.HandleFunc("POST /app/assets", a.requireAdmin(a.handleAssetCreate))
|
||||
mux.HandleFunc("PATCH /app/assets/{id}", a.requireAdmin(a.handleAssetUpdate))
|
||||
mux.HandleFunc("DELETE /app/assets/{id}", a.requireAdmin(a.handleAssetDelete))
|
||||
mux.HandleFunc("POST /app/assets/{id}/duplicate", a.requireAdmin(a.handleAssetDuplicate))
|
||||
mux.HandleFunc("POST /app/assets/{id}/move", a.requireAdmin(a.handleAssetMove))
|
||||
|
||||
// CSV import/export
|
||||
mux.HandleFunc("GET /export/assets.csv", a.requireLogin(a.handleExportCSV))
|
||||
mux.HandleFunc("POST /import/assets.csv", a.requireAdmin(a.handleImportCSV))
|
||||
|
||||
// Item types (admin, traditional pages)
|
||||
mux.HandleFunc("GET /item-types", a.requireAdmin(a.handleItemTypesList))
|
||||
mux.HandleFunc("GET /item-types/new", a.requireAdmin(a.handleItemTypeNewForm))
|
||||
mux.HandleFunc("POST /item-types", a.requireAdmin(a.handleItemTypeCreate))
|
||||
mux.HandleFunc("GET /item-types/{id}/edit", a.requireAdmin(a.handleItemTypeEditForm))
|
||||
mux.HandleFunc("POST /item-types/{id}", a.requireAdmin(a.handleItemTypeUpdate))
|
||||
mux.HandleFunc("POST /item-types/{id}/delete", a.requireAdmin(a.handleItemTypeDelete))
|
||||
mux.HandleFunc("POST /item-types/{id}/fields", a.requireAdmin(a.handleFieldCreate))
|
||||
mux.HandleFunc("POST /item-types/{id}/fields/{fieldID}", a.requireAdmin(a.handleFieldUpdate))
|
||||
mux.HandleFunc("POST /item-types/{id}/fields/{fieldID}/delete", a.requireAdmin(a.handleFieldDelete))
|
||||
|
||||
// Users (admin, traditional pages)
|
||||
mux.HandleFunc("GET /users", a.requireAdmin(a.handleUsersList))
|
||||
mux.HandleFunc("GET /users/new", a.requireAdmin(a.handleUserNewForm))
|
||||
mux.HandleFunc("POST /users", a.requireAdmin(a.handleUserCreate))
|
||||
mux.HandleFunc("GET /users/{id}/edit", a.requireAdmin(a.handleUserEditForm))
|
||||
mux.HandleFunc("POST /users/{id}", a.requireAdmin(a.handleUserUpdate))
|
||||
mux.HandleFunc("POST /users/{id}/password", a.requireAdmin(a.handleUserPassword))
|
||||
mux.HandleFunc("POST /users/{id}/delete", a.requireAdmin(a.handleUserDelete))
|
||||
|
||||
// Settings (admin, traditional page)
|
||||
mux.HandleFunc("GET /settings", a.requireAdmin(a.handleSettingsPage))
|
||||
mux.HandleFunc("POST /settings/general", a.requireAdmin(a.handleSettingsGeneral))
|
||||
mux.HandleFunc("POST /settings/statuses", a.requireAdmin(a.handleStatusCreate))
|
||||
mux.HandleFunc("POST /settings/statuses/{id}", a.requireAdmin(a.handleStatusUpdate))
|
||||
mux.HandleFunc("POST /settings/statuses/{id}/delete", a.requireAdmin(a.handleStatusDelete))
|
||||
mux.HandleFunc("POST /settings/statuses/{id}/reorder", a.requireAdmin(a.handleStatusReorder))
|
||||
|
||||
return a.loadSession(mux)
|
||||
}
|
||||
|
||||
func (a *App) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.DB.PingContext(r.Context()); err != nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "database unavailable")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
func (a *App) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/inventory", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package web
|
||||
|
||||
import "io/fs"
|
||||
|
||||
// mustSub returns an FS rooted at dir within root, panicking on error (dir
|
||||
// is always a compile-time-known embedded path, so this can only fail if the
|
||||
// embed itself is misconfigured).
|
||||
func mustSub(root fs.FS, dir string) fs.FS {
|
||||
sub, err := fs.Sub(root, dir)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"inventree/internal/assets"
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
// treeAssetLimit bounds how many assets are rendered directly inside a
|
||||
// single tree node before showing a "N more" placeholder, so a folder with
|
||||
// thousands of assets doesn't blow up the tree fragment.
|
||||
const treeAssetLimit = 300
|
||||
|
||||
// expandedFolderIDs returns the set of folder IDs the given user has
|
||||
// persisted as expanded.
|
||||
func (a *App) expandedFolderIDs(ctx context.Context, userID int64) (map[int64]bool, error) {
|
||||
rows, err := a.DB.QueryContext(ctx, `SELECT folder_id FROM user_tree_state WHERE user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int64]bool{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) persistExpand(ctx context.Context, userID, folderID int64) error {
|
||||
_, err := a.DB.ExecContext(ctx,
|
||||
`INSERT INTO user_tree_state (user_id, folder_id) VALUES (?, ?) ON CONFLICT(user_id, folder_id) DO NOTHING`,
|
||||
userID, folderID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) persistCollapse(ctx context.Context, userID, folderID int64) error {
|
||||
_, err := a.DB.ExecContext(ctx, `DELETE FROM user_tree_state WHERE user_id = ? AND folder_id = ?`, userID, folderID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) persistCollapseAll(ctx context.Context, userID int64) error {
|
||||
_, err := a.DB.ExecContext(ctx, `DELETE FROM user_tree_state WHERE user_id = ?`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// buildTree returns the ordered list of tree nodes (subfolders then assets)
|
||||
// directly under parentID. Folders present in the expanded set are
|
||||
// recursively expanded inline; others are returned collapsed for the client
|
||||
// to lazy-load on demand.
|
||||
func (a *App) buildTree(ctx context.Context, parentID *int64, expanded map[int64]bool) ([]models.TreeNode, error) {
|
||||
childFolders, err := a.Folders.Children(ctx, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []models.TreeNode
|
||||
for _, f := range childFolders {
|
||||
node := models.TreeNode{
|
||||
Type: "folder",
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
Icon: f.Icon,
|
||||
ParentID: f.ParentID,
|
||||
HasChildren: f.FolderCount > 0 || f.AssetCount > 0,
|
||||
}
|
||||
if expanded[f.ID] {
|
||||
node.Expanded = true
|
||||
children, err := a.buildTree(ctx, &f.ID, expanded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node.Children = children
|
||||
}
|
||||
out = append(out, node)
|
||||
}
|
||||
|
||||
assetList, total, err := a.Assets.List(ctx, assets.ListFilter{
|
||||
FolderID: parentID, FolderSet: true, Page: 1, PageSize: treeAssetLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, as := range assetList {
|
||||
out = append(out, models.TreeNode{
|
||||
Type: "asset",
|
||||
ID: as.ID,
|
||||
Name: as.Name,
|
||||
Icon: as.ItemTypeIcon,
|
||||
AssetTag: as.AssetTag,
|
||||
StatusName: as.StatusName,
|
||||
StatusColor: as.StatusColor,
|
||||
})
|
||||
}
|
||||
if total > len(assetList) {
|
||||
out = append(out, models.TreeNode{
|
||||
Type: "more",
|
||||
Name: fmt.Sprintf("%d more asset(s) — open this folder to view all", total-len(assetList)),
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func fieldValuesForCreate(fields []models.CustomFieldDef) []models.AssetFieldValue {
|
||||
out := make([]models.AssetFieldValue, len(fields))
|
||||
for i, f := range fields {
|
||||
out[i] = models.AssetFieldValue{Def: f, Value: f.DefaultValue}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"inventree/internal/database"
|
||||
"inventree/internal/models"
|
||||
"inventree/internal/web"
|
||||
)
|
||||
|
||||
func testApp(t *testing.T) *web.App {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := database.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
app, err := web.NewApp(db, log, false)
|
||||
if err != nil {
|
||||
t.Fatalf("new app: %v", err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// loginAs signs in and returns the session cookie for subsequent requests.
|
||||
func loginAs(t *testing.T, srv *httptest.Server, username, password string) *http.Cookie {
|
||||
t.Helper()
|
||||
form := "username=" + username + "&password=" + password
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/login", strings.NewReader(form))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("login request: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect on login, got %d", res.StatusCode)
|
||||
}
|
||||
for _, c := range res.Cookies() {
|
||||
if c.Name == "inventree_session" {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatal("no session cookie set")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
app := testApp(t)
|
||||
srv := httptest.NewServer(app.Router())
|
||||
defer srv.Close()
|
||||
|
||||
res, err := http.Get(srv.URL + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAndInventoryPage(t *testing.T) {
|
||||
app := testApp(t)
|
||||
srv := httptest.NewServer(app.Router())
|
||||
defer srv.Close()
|
||||
|
||||
cookie := loginAs(t, srv, "admin", "admin")
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inventory", nil)
|
||||
req.AddCookie(cookie)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get inventory: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", res.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if !bytes.Contains(body, []byte("Inventory")) {
|
||||
t.Fatalf("expected inventory page content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthenticatedRedirectsToLogin(t *testing.T) {
|
||||
app := testApp(t)
|
||||
srv := httptest.NewServer(app.Router())
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
res, err := client.Get(srv.URL + "/inventory")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect to login, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderCreateRequiresAdmin(t *testing.T) {
|
||||
app := testApp(t)
|
||||
srv := httptest.NewServer(app.Router())
|
||||
defer srv.Close()
|
||||
|
||||
// Create a normal (non-admin) user directly via the store.
|
||||
normalUser := &models.User{Username: "normie", Role: models.RoleUser, Enabled: true}
|
||||
if err := app.Users.Create(t.Context(), normalUser, "normiepass"); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
cookie := loginAs(t, srv, "normie", "normiepass")
|
||||
|
||||
payload := `{"name":"Should Fail"}`
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/app/folders", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("post folder: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for non-admin folder create, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderAndAssetLifecycleAsAdmin(t *testing.T) {
|
||||
app := testApp(t)
|
||||
srv := httptest.NewServer(app.Router())
|
||||
defer srv.Close()
|
||||
|
||||
cookie := loginAs(t, srv, "admin", "admin")
|
||||
|
||||
folderID := postJSON(t, srv, cookie, "/app/folders", `{"name":"Main Office"}`)["ID"].(float64)
|
||||
|
||||
assetPayload := `{"name":"Core Switch","item_type_id":4,"folder_id":` + itoa(int64(folderID)) + `,"status_id":1,"fields":{}}`
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/app/assets", strings.NewReader(assetPayload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("post asset: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Fatalf("expected 200 creating asset, got %d: %s", res.StatusCode, body)
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
AssetID string `json:"asset_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if envelope.Data.AssetID != "IT-000001" {
|
||||
t.Fatalf("expected auto-generated IT-000001, got %s", envelope.Data.AssetID)
|
||||
}
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, srv *httptest.Server, cookie *http.Cookie, path, payload string) map[string]any {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+path, strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("post %s: %v", path, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Fatalf("expected 200 for %s, got %d: %s", path, res.StatusCode, body)
|
||||
}
|
||||
var envelope struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
func itoa(n int64) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte('0' + n%10)}, b...)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
b = append([]byte{'-'}, b...)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user