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 */}
+
+
+ {/* 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 (
+ <>
+
+
+
+
+ {/* Header */}
+
+
+
+ {/* Rows */}
+ {buckets.map(bucket => (
+ <>
+
+
+
+
+
+
+
+ >
+ ))}
+
+
+
+
+
+ >
+ )
+}
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
//