diff --git a/_frontend/src/components/MainContent.jsx b/_frontend/src/components/MainContent.jsx index 90d2024..b0f08c4 100644 --- a/_frontend/src/components/MainContent.jsx +++ b/_frontend/src/components/MainContent.jsx @@ -7,6 +7,8 @@ import { AdminPanel } from '../pages/Admin.jsx' import { ApiKeys } from '../pages/ApiKeys.jsx' import { Login } from '../pages/Login.jsx' import { Dashboard } from '../pages/Dashboard.jsx' +import { Buckets } from '../pages/Buckets.jsx' +import { Bucket } from '../pages/Bucket.jsx' export const MainContent = () => { const { setSidebarVisible, route, user } = useStore() @@ -17,6 +19,8 @@ export const MainContent = () => { login: Login, admin: AdminPanel, apiKeys: ApiKeys, + buckets: Buckets, + bucket: Bucket, }[route.id] || (() => <>Still nothing here) return ( diff --git a/_frontend/src/main.jsx b/_frontend/src/main.jsx index a4a15e9..03a5f97 100644 --- a/_frontend/src/main.jsx +++ b/_frontend/src/main.jsx @@ -39,6 +39,10 @@ const App = () => { }, }) + if (id === 'unknown') { + location.href = '/' + } + const user = useUser(user => { if (id !== 'login' && user !== 'admin') { location.href = '/login' diff --git a/_frontend/src/pages/Bucket.jsx b/_frontend/src/pages/Bucket.jsx new file mode 100644 index 0000000..dcc8934 --- /dev/null +++ b/_frontend/src/pages/Bucket.jsx @@ -0,0 +1,106 @@ +import { useEffect, useRef, useState } from 'preact/hooks' +import { Icon } from '../components/Icon.jsx' +import { useStore } from '../context.jsx' +import { useRemoteState } from '../util.jsx' + +export const Bucket = () => { + const { + route: { + params: { bucket }, + }, + showToast, + } = useStore() + + const [bucketObjects, updateBucketObjects] = useRemoteState(`/api/buckets/${bucket}`, []) + + const fileInputRef = useRef(null) + + const uploadFile = async () => { + const formData = new FormData() + + formData.append('file', fileInputRef.current.files[0]) + + showToast(<>Sto caricando il file...) + + const res = await fetch(`/api/buckets/${bucket}`, { + method: 'POST', + body: formData, + }) + + if (res.ok) { + const { id } = await res.json() + + showToast( + <> + File caricato con id {id} + + ) + + await updateBucketObjects() + } + } + + const deleteObject = async id => { + const res = await fetch(`/api/buckets/${bucket}/${id}`, { method: 'DELETE' }) + + if (res.ok) { + showToast('Oggetto rimosso') + } else { + showToast(`Errore: ${await res.text()}`) + } + } + + return ( + <> +
+
+
+
+ Bucket / {bucket} +
+
+
+ + uploadFile()} + style="display: none;" + /> +
+
+
+
+ {/* Header */} +
Objects
+
Actions
+ {/* Rows */} + {bucketObjects.map(key => ( + <> +
+ {key} +
+
+ + + + +
+ + ))} +
+
+
+ + ) +} diff --git a/_frontend/src/pages/Buckets.jsx b/_frontend/src/pages/Buckets.jsx new file mode 100644 index 0000000..b4bf5e9 --- /dev/null +++ b/_frontend/src/pages/Buckets.jsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'preact/hooks' +import { Icon } from '../components/Icon.jsx' +import { showToast } from '../components/Toasts.jsx' +import { useRemoteState } from '../util.jsx' + +export const Buckets = () => { + const [buckets, updateBuckets] = useRemoteState('/api/buckets', []) + const [allBucketSettings, setAllBucketSettings] = useState({}) + + const updateBucketSettings = async bucket => { + const res = await fetch(`/api/buckets/${bucket}/settings`) + const value = await res.json() + + setAllBucketSettings(s => ({ ...s, [bucket]: value })) + } + + useEffect(() => { + for (const bucket of buckets) { + updateBucketSettings(bucket) + } + }, [buckets]) + + const [newBucketName, setNewBucketName] = useState('') + const [newBucketSettings, setNewBucketSettings] = useState('') + + const createBucket = async () => { + const { path } = JSON.parse(newBucketSettings || '{"path":""}') + + const res = await fetch(`/api/buckets/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + bucket: newBucketName, + path, + }), + }) + + if (res.ok) { + await updateBuckets() + } else { + showToast(`Errore: ${await res.text()}`) + } + } + + return ( + <> +
+
+
+
Buckets
+
+
+
+
+ {/* Header */} +
Bucket
+
Settings
+
Options
+ {/* Rows */} + {buckets.map(bucket => ( + <> + +
+ +
+
+ +
+ + ))} +
+
+
+ +
+
Nuovo Bucket
+ + + setNewBucketName(e.target.value)} + /> + + setNewBucketSettings(e.target.value)} + /> + +
+ +
+
+ + ) +} diff --git a/_frontend/src/util.jsx b/_frontend/src/util.jsx index f2cc7df..2051cb2 100644 --- a/_frontend/src/util.jsx +++ b/_frontend/src/util.jsx @@ -33,8 +33,14 @@ export function useUser(onLoginStateChanged) { return user } +// +// Hooks +// + +// Router + function matchPattern(pattern, url) { - const r = `^${pattern.replace(/:([a-zA-Z0-9\_\-]+)/g, '(?<$1>.+?)')}$` + const r = `^${pattern.replace(/:([a-zA-Z0-9\_\-]+)/g, '(?<$1>[^\\/\\?]+?)')}$` return new RegExp(r).exec(url) } @@ -49,6 +55,27 @@ export const useRouter = routes => { return ['unknown', { id: 'unknown' }, {}] } +// useRemoteState + +export function useRemoteState(url, initialValue = null) { + const [value, setValue] = useState(initialValue) + + const updateValue = async () => { + const res = await fetch(url) + if (!res.ok) { + console.error(res.body) + } + + setValue(await res.json()) + } + + useEffect(() => { + updateValue() + }, []) + + return [value, updateValue] +} + // // Change Case Utility // diff --git a/_frontend/styles/main.scss b/_frontend/styles/main.scss index f86ad2a..226ed1d 100644 --- a/_frontend/styles/main.scss +++ b/_frontend/styles/main.scss @@ -13,6 +13,7 @@ --bg-dark-400: #55555f; --bg-dark-500: #38383d; + --fg-light-300: #5d5d5d; --fg-light-400: #3d3d3d; --fg-light-500: #333; @@ -215,6 +216,8 @@ main { flex-direction: column; align-items: center; + gap: 1rem; + flex-grow: 1; padding: 1rem 1rem 0 1rem; @@ -265,6 +268,43 @@ main { // Components // +.toasts { + position: absolute; + z-index: 10; + + left: 50%; + transform: translateX(-50%); + + bottom: 1rem; + + display: flex; + flex-direction: column; + align-items: center; + + gap: 0.25rem; + + transition: all 150ms linear; + + font-size: 15px; + + .toast { + background: #38383d; + color: var(--fg-dark-500); + + padding: 0.5rem 1rem; + border-radius: 0.5rem; + + opacity: 0.95; + + animation: toast-fade-in 250ms ease-out; + transition: opacity 250ms ease-in; + + &.removed { + opacity: 0; + } + } +} + .panel { background: var(--bg-light-100); border: 1px solid var(--bg-light-600); @@ -277,6 +317,10 @@ main { & .title { font-weight: var(--ft-sans-wt-bold); + + code { + font-weight: var(--ft-sans-wt-normal); + } } } @@ -301,6 +345,9 @@ main { & > div { min-width: 4rem; + + display: flex; + align-items: center; } } } @@ -385,6 +432,15 @@ button, background: var(--bg-light-700); } } + + &.delete, + &.error { + color: #e62929; + + &:hover { + background: #e629291d; + } + } } input[type='text'], @@ -408,6 +464,11 @@ textarea { font-size: 90%; font-family: var(--ft-mono); } + + &:read-only { + color: var(--fg-light-300); + background: var(--bg-light-500); + } } textarea { @@ -540,51 +601,36 @@ $heading-scale: 1.33; display: flex; justify-content: end; gap: 0.5rem; - - .delete { - color: #e62929; - - &:hover { - background: #e629291d; - } - } } } } -.toasts { - position: absolute; - z-index: 10; - - left: 50%; - transform: translateX(-50%); - - bottom: 1rem; - - display: flex; - flex-direction: column; - align-items: center; - - gap: 0.25rem; - - transition: all 150ms linear; - - font-size: 15px; +.route-buckets { + .table { + min-width: 30rem; - .toast { - background: #38383d; - color: var(--fg-dark-500); + .cells { + grid-template-columns: 1fr auto auto; + gap: 0.25rem 1rem; - padding: 0.5rem 1rem; - border-radius: 0.5rem; + .settings { + min-width: 20rem; - opacity: 0.95; + input[type='text'] { + width: 100%; + } + } + } + } +} - animation: toast-fade-in 250ms ease-out; - transition: opacity 250ms ease-in; +.route-bucket { + .table { + min-width: 30rem; - &.removed { - opacity: 0; + .cells { + grid-template-columns: 1fr auto; + gap: 0.25rem 1rem; } } } diff --git a/database/database.go b/database/database.go index a2bd024..991e74f 100644 --- a/database/database.go +++ b/database/database.go @@ -29,33 +29,40 @@ type Database interface { // API Keys + AllAPIKeys() ([]string, error) + CreateAPIKey() (string, error) CheckAPIKey(key string) error RemoveAPIKey(key string) error - AllAPIKeys() ([]string, error) // Bucket Operations - CreateBucket(bucket string, options ...any) error - DeleteBucket(bucket string) error - AllBuckets() ([]string, error) + CreateBucket(bucket string, settings any) error + GetBucketSettings(bucket string) (any, error) + SetBucketSettings(bucket string, settings any) error + DeleteBucket(bucket string) error + // Bucket Object Operations - CreateBucketObject(bucket string, r io.Reader) (string, error) - DeleteBucketObject(bucket, id string) error + AllBucketObjects(bucket string) ([]string, error) + CreateBucketObject(bucket string, r io.Reader) (string, error) GetBucketObject(bucket, id string, w io.Writer) error SetBucketObject(bucket, id string, r io.Reader) error + DeleteBucketObject(bucket, id string) error +} - AllBucketObjects(bucket string) ([]string, error) +type JsonBucketSettings struct { + Path string `json:"path"` } type jsonBucket struct { Name string `json:"name"` - Path string `json:"path"` Objects []string `json:"objects"` + + Settings *JsonBucketSettings `json:"settings"` } type jsonDB struct { @@ -207,7 +214,7 @@ func (db *jsonDB) CheckAPIKey(key string) error { } if !slices.Contains(db.APIKeys, key) { - return fmt.Errorf("the given API key %q is invalid", key) + return fmt.Errorf("the given api key %q is invalid", key) } return nil @@ -220,7 +227,7 @@ func (db *jsonDB) RemoveAPIKey(key string) error { i := slices.Index(db.APIKeys, key) if i == -1 { - return fmt.Errorf("the given API key %q is invalid", key) + return fmt.Errorf("the given api key %q is invalid", key) } db.APIKeys = append(db.APIKeys[:i], db.APIKeys[i+1:]...) @@ -232,7 +239,11 @@ func (db *jsonDB) RemoveAPIKey(key string) error { return nil } -func (db *jsonDB) CreateBucket(bucket string, options ...any) error { +// +// Bucket Methods +// + +func (db *jsonDB) CreateBucket(bucket string, settings any) error { if err := db.load(); err != nil { return err } @@ -242,12 +253,15 @@ func (db *jsonDB) CreateBucket(bucket string, options ...any) error { } bi := &jsonBucket{ - Name: bucket, - Path: bucket + "/", + Name: bucket, + Settings: &JsonBucketSettings{ + Path: bucket + "/", + }, Objects: []string{}, } - if len(options) > 0 { - bi.Path = options[0].(string) + + if settings != nil { + bi.Settings = settings.(*JsonBucketSettings) } db.Buckets[bucket] = bi @@ -259,6 +273,39 @@ func (db *jsonDB) CreateBucket(bucket string, options ...any) error { return nil } +func (db *jsonDB) GetBucketSettings(bucket string) (any, error) { + if err := db.load(); err != nil { + return nil, err + } + + if _, found := db.Buckets[bucket]; !found { + return nil, fmt.Errorf("bucket named %q not found", bucket) + } + + return db.Buckets[bucket].Settings, nil +} + +func (db *jsonDB) SetBucketSettings(bucket string, settings any) error { + if err := db.load(); err != nil { + return err + } + + b, found := db.Buckets[bucket] + if !found { + return fmt.Errorf("bucket named %q not found", bucket) + } + + b.Settings = settings.(*JsonBucketSettings) + + log.Printf("[Warning] Bucket %q settings changed but bucket migration is not yet implemented (move the folder yourself)", bucket) + + if err := db.store(); err != nil { + return err + } + + return nil +} + func (db *jsonDB) DeleteBucket(bucket string) error { if err := db.load(); err != nil { return err @@ -303,7 +350,7 @@ func (db *jsonDB) CreateBucketObject(bucket string, r io.Reader) (string, error) return "", fmt.Errorf("bucket named %q not found", bucket) } - id, err := store.Create(b.Path, db.PrefixSize, r) + id, err := store.Create(b.Settings.Path, db.PrefixSize, r) if err != nil { return "", err } @@ -331,7 +378,7 @@ func (db *jsonDB) SetBucketObject(bucket, id string, r io.Reader) error { return err } - return store.Update(b.Path, db.PrefixSize, id, r) + return store.Update(b.Settings.Path, db.PrefixSize, id, r) } func (db *jsonDB) GetBucketObject(bucket, id string, w io.Writer) error { @@ -344,7 +391,7 @@ func (db *jsonDB) GetBucketObject(bucket, id string, w io.Writer) error { return fmt.Errorf("bucket named %q not found", bucket) } - return store.Read(b.Path, db.PrefixSize, id, w) + return store.Read(b.Settings.Path, db.PrefixSize, id, w) } func (db *jsonDB) DeleteBucketObject(bucket, id string) error { @@ -357,7 +404,7 @@ func (db *jsonDB) DeleteBucketObject(bucket, id string) error { return fmt.Errorf("bucket named %q not found", bucket) } - if err := store.Delete(b.Path, db.PrefixSize, id); err != nil { + if err := store.Delete(b.Settings.Path, db.PrefixSize, id); err != nil { return err } @@ -381,7 +428,7 @@ func (db *jsonDB) AllBucketObjects(bucket string) ([]string, error) { return nil, fmt.Errorf("bucket named %q not found", bucket) } - objects, err := store.All(b.Path) + objects, err := store.All(b.Settings.Path) if err != nil { return nil, err } diff --git a/routes/api.go b/routes/api.go index ca34fa0..f8ec9e6 100644 --- a/routes/api.go +++ b/routes/api.go @@ -22,9 +22,15 @@ func (r *Router) Api(api fiber.Router) { return c.Next() } + isAPIKeyMiddleware := func(c *fiber.Ctx) error { if _, isAdmin := adminSessions[c.Cookies("sid")]; !isAdmin { // if admin continue - if err := r.Database.CheckAPIKey(c.Cookies("token")); err != nil { // otherwise also check api token + token := c.Cookies("token") + if token == "" { + return fmt.Errorf("no api token") + } + + if err := r.Database.CheckAPIKey(token); err != nil { // otherwise also check api token return err } } @@ -120,12 +126,12 @@ func (r *Router) Api(api fiber.Router) { return err } - opts := []any{} + settings := &database.JsonBucketSettings{} if req.Path != "" { - opts = append(opts, req.Path) + settings.Path = req.Path } - if err := r.Database.CreateBucket(req.Bucket, opts...); err != nil { + if err := r.Database.CreateBucket(req.Bucket, settings); err != nil { return err } @@ -145,6 +151,37 @@ func (r *Router) Api(api fiber.Router) { return c.JSON(objects) }) + api.Get("/buckets/:bucket/settings", + isAdminMiddleware, + func(c *fiber.Ctx) error { + bucket := c.Params("bucket") + + settings, err := r.Database.GetBucketSettings(bucket) + if err != nil { + return err + } + + return c.JSON(settings) + }) + + api.Post("/buckets/:bucket/settings", + isAdminMiddleware, + func(c *fiber.Ctx) error { + bucket := c.Params("bucket") + + var settings *database.JsonBucketSettings + + if err := c.BodyParser(&settings); err != nil { + return err + } + + if err := r.Database.SetBucketSettings(bucket, settings); err != nil { + return err + } + + return c.JSON("ok") + }) + api.Post("/buckets/:bucket", isAPIKeyMiddleware, func(c *fiber.Ctx) error { @@ -186,6 +223,19 @@ func (r *Router) Api(api fiber.Router) { return c.SendStream(buf) }) + api.Delete("/buckets/:bucket/:id", + isAPIKeyMiddleware, + func(c *fiber.Ctx) error { + bucket := c.Params("bucket") + id := c.Params("id") + + if err := r.Database.DeleteBucketObject(bucket, id); err != nil { + return err + } + + return c.JSON("ok") + }) + // // API Keys //