aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorQuentin <quentin@dufour.io>2023-09-26 06:44:36 +0000
committerQuentin <quentin@dufour.io>2023-09-26 06:44:36 +0000
commit49d8e81fbea0d4703a33e87a807927169a8060ac (patch)
treed0b655454d5e13ed2238060fee27fc0d951d64c8
parent1e75c21b65021da0c3c5a8be9be12114a2327464 (diff)
parent706ff58a6f6608719feda15075d50f978df39c5b (diff)
downloadguichet-49d8e81fbea0d4703a33e87a807927169a8060ac.tar.gz
guichet-49d8e81fbea0d4703a33e87a807927169a8060ac.zip
Merge pull request 'An API for Guichet' (#23) from api into main
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/guichet/pulls/23
-rw-r--r--admin.go98
-rw-r--r--api.go127
-rw-r--r--cli.go44
-rw-r--r--directory.go10
-rw-r--r--garage.go242
-rw-r--r--go.mod3
-rw-r--r--go.sum5
-rw-r--r--integration/config/bottin.json10
-rw-r--r--integration/docker-compose.yml8
-rw-r--r--invite.go37
-rw-r--r--login.go294
-rw-r--r--main.go224
-rw-r--r--picture.go10
-rw-r--r--profile.go40
-rw-r--r--quotas.go151
-rw-r--r--templates/garage_key.html22
-rw-r--r--templates/garage_website_inspect.html123
-rw-r--r--templates/garage_website_list.html38
-rw-r--r--templates/garage_website_new.html13
-rw-r--r--templates/home.html12
-rw-r--r--templates/profile.html2
-rw-r--r--website.go236
22 files changed, 1261 insertions, 488 deletions
diff --git a/admin.go b/admin.go
index 18d1fb2..3c805fb 100644
--- a/admin.go
+++ b/admin.go
@@ -11,18 +11,18 @@ import (
"github.com/gorilla/mux"
)
-func checkAdminLogin(w http.ResponseWriter, r *http.Request) *LoginStatus {
- login := checkLogin(w, r)
- if login == nil {
+func checkAdminLogin(w http.ResponseWriter, r *http.Request) *LoggedUser {
+ user := RequireUserHtml(w, r)
+ if user == nil {
return nil
}
- if !login.CanAdmin {
+ if !user.Capabilities.CanAdmin {
http.Error(w, "Not authorized to perform administrative operations.", http.StatusUnauthorized)
return nil
}
- return login
+ return user
}
type EntryList []*ldap.Entry
@@ -40,7 +40,7 @@ func (d EntryList) Less(i, j int) bool {
}
type AdminUsersTplData struct {
- Login *LoginStatus
+ User *LoggedUser
UserNameAttr string
UserBaseDN string
Users EntryList
@@ -49,8 +49,8 @@ type AdminUsersTplData struct {
func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
templateAdminUsers := getTemplate("admin_users.html")
- login := checkAdminLogin(w, r)
- if login == nil {
+ user := checkAdminLogin(w, r)
+ if user == nil {
return
}
@@ -61,14 +61,14 @@ func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
[]string{config.UserNameAttr, "dn", "displayname", "givenname", "sn", "mail"},
nil)
- sr, err := login.conn.Search(searchRequest)
+ sr, err := user.Login.conn.Search(searchRequest)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := &AdminUsersTplData{
- Login: login,
+ User: user,
UserNameAttr: config.UserNameAttr,
UserBaseDN: config.UserBaseDN,
Users: EntryList(sr.Entries),
@@ -79,7 +79,7 @@ func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
}
type AdminGroupsTplData struct {
- Login *LoginStatus
+ User *LoggedUser
GroupNameAttr string
GroupBaseDN string
Groups EntryList
@@ -88,8 +88,8 @@ type AdminGroupsTplData struct {
func handleAdminGroups(w http.ResponseWriter, r *http.Request) {
templateAdminGroups := getTemplate("admin_groups.html")
- login := checkAdminLogin(w, r)
- if login == nil {
+ user := checkAdminLogin(w, r)
+ if user == nil {
return
}
@@ -100,14 +100,14 @@ func handleAdminGroups(w http.ResponseWriter, r *http.Request) {
[]string{config.GroupNameAttr, "dn", "description"},
nil)
- sr, err := login.conn.Search(searchRequest)
+ sr, err := user.Login.conn.Search(searchRequest)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := &AdminGroupsTplData{
- Login: login,
+ User: user,
GroupNameAttr: config.GroupNameAttr,
GroupBaseDN: config.GroupBaseDN,
Groups: EntryList(sr.Entries),
@@ -118,7 +118,7 @@ func handleAdminGroups(w http.ResponseWriter, r *http.Request) {
}
type AdminMailingTplData struct {
- Login *LoginStatus
+ User *LoggedUser
MailingNameAttr string
MailingBaseDN string
MailingLists EntryList
@@ -127,8 +127,8 @@ type AdminMailingTplData struct {
func handleAdminMailing(w http.ResponseWriter, r *http.Request) {
templateAdminMailing := getTemplate("admin_mailing.html")
- login := checkAdminLogin(w, r)
- if login == nil {
+ user := checkAdminLogin(w, r)
+ if user == nil {
return
}
@@ -139,14 +139,14 @@ func handleAdminMailing(w http.ResponseWriter, r *http.Request) {
[]string{config.MailingNameAttr, "dn", "description"},
nil)
- sr, err := login.conn.Search(searchRequest)
+ sr, err := user.Login.conn.Search(searchRequest)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := &AdminMailingTplData{
- Login: login,
+ User: user,
MailingNameAttr: config.MailingNameAttr,
MailingBaseDN: config.MailingBaseDN,
MailingLists: EntryList(sr.Entries),
@@ -157,7 +157,7 @@ func handleAdminMailing(w http.ResponseWriter, r *http.Request) {
}
type AdminMailingListTplData struct {
- Login *LoginStatus
+ User *LoggedUser
MailingNameAttr string
MailingBaseDN string
@@ -173,8 +173,8 @@ type AdminMailingListTplData struct {
func handleAdminMailingList(w http.ResponseWriter, r *http.Request) {
templateAdminMailingList := getTemplate("admin_mailing_list.html")
- login := checkAdminLogin(w, r)
- if login == nil {
+ user := checkAdminLogin(w, r)
+ if user == nil {
return
}
@@ -193,7 +193,7 @@ func handleAdminMailingList(w http.ResponseWriter, r *http.Request) {
modify_request := ldap.NewModifyRequest(dn, nil)
modify_request.Add("member", []string{member})
- err := login.conn.Modify(modify_request)
+ err := user.Login.conn.Modify(modify_request)
if err != nil {
dError = err.Error()
} else {
@@ -209,7 +209,7 @@ func handleAdminMailingList(w http.ResponseWriter, r *http.Request) {
fmt.Sprintf("(&(objectClass=organizationalPerson)(mail=%s))", mail),
[]string{"dn", "displayname", "mail"},
nil)
- sr, err := login.conn.Search(searchRequest)
+ sr, err := user.Login.conn.Search(searchRequest)
if err != nil {
dError = err.Error()
} else {
@@ -222,14 +222,14 @@ func handleAdminMailingList(w http.ResponseWriter, r *http.Request) {
if displayname != "" {
req.Attribute("displayname", []string{displayname})
}
- err := login.conn.Add(req)
+ err := user.Login.conn.Add(req)
if err != nil {
dError = err.Error()
} else {
modify_request := ldap.NewModifyRequest(dn, nil)
modify_request.Add("member", []string{guestDn})
- err := login.conn.Modify(modify_request)
+ err := user.Login.conn.Modify(modify_request)
if err != nil {
dError = err.Error()
} else {
@@ -243,7 +243,7 @@ func handleAdminMailingList(w http.ResponseWriter, r *http.Request) {
modify_request := ldap.NewModifyRequest(dn, nil)
modify_request.Add("member", []string{sr.Entries[0].DN})
- err := login.conn.Modify(modify_request)
+ err := user.Login.conn.Modify(modify_request)
if err != nil {
dError = err.Error()
} else {
@@ -258,7 +258,7 @@ func handleAdminMailingList(w http.ResponseWriter, r *http.Request) {
modify_request := ldap.NewModifyRequest(dn, nil)
modify_request.Delete("member", []string{member})
- err := login.conn.Modify(modify_request)
+ err := user.Login.conn.Modify(modify_request)
if err != nil {
dError = err.Error()
} else {
@@ -275,7 +275,7 @@ func handleAdminMailingList(w http.ResponseWriter, r *http.Request) {
[]string{"dn", config.MailingNameAttr, "member", "description"},
nil)
- sr, err := login.conn.Search(searchRequest)
+ sr, err := user.Login.conn.Search(searchRequest)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -307,7 +307,7 @@ func handleAdminMailingList(w http.ResponseWriter, r *http.Request) {
fmt.Sprintf("(objectClass=organizationalPerson)"),
[]string{"dn", "displayname", "mail"},
nil)
- sr, err = login.conn.Search(searchRequest)
+ sr, err = user.Login.conn.Search(searchRequest)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -322,7 +322,7 @@ func handleAdminMailingList(w http.ResponseWriter, r *http.Request) {
}
data := &AdminMailingListTplData{
- Login: login,
+ User: user,
MailingNameAttr: config.MailingNameAttr,
MailingBaseDN: config.MailingBaseDN,
@@ -394,8 +394,8 @@ type PropValues struct {
func handleAdminLDAP(w http.ResponseWriter, r *http.Request) {
templateAdminLDAP := getTemplate("admin_ldap.html")
- login := checkAdminLogin(w, r)
- if login == nil {
+ user := checkAdminLogin(w, r)
+ if user == nil {
return
}
@@ -445,7 +445,7 @@ func handleAdminLDAP(w http.ResponseWriter, r *http.Request) {
modify_request := ldap.NewModifyRequest(dn, nil)
modify_request.Replace(attr, values_filtered)
- err := login.conn.Modify(modify_request)
+ err := user.Login.conn.Modify(modify_request)
if err != nil {
dError = err.Error()
} else {
@@ -466,7 +466,7 @@ func handleAdminLDAP(w http.ResponseWriter, r *http.Request) {
modify_request := ldap.NewModifyRequest(dn, nil)
modify_request.Add(attr, values_filtered)
- err := login.conn.Modify(modify_request)
+ err := user.Login.conn.Modify(modify_request)
if err != nil {
dError = err.Error()
} else {
@@ -478,7 +478,7 @@ func handleAdminLDAP(w http.ResponseWriter, r *http.Request) {
modify_request := ldap.NewModifyRequest(dn, nil)
modify_request.Replace(attr, []string{})
- err := login.conn.Modify(modify_request)
+ err := user.Login.conn.Modify(modify_request)
if err != nil {
dError = err.Error()
} else {
@@ -489,7 +489,7 @@ func handleAdminLDAP(w http.ResponseWriter, r *http.Request) {
modify_request := ldap.NewModifyRequest(group, nil)
modify_request.Delete("member", []string{dn})
- err := login.conn.Modify(modify_request)
+ err := user.Login.conn.Modify(modify_request)
if err != nil {
dError = err.Error()
} else {
@@ -500,7 +500,7 @@ func handleAdminLDAP(w http.ResponseWriter, r *http.Request) {
modify_request := ldap.NewModifyRequest(group, nil)
modify_request.Add("member", []string{dn})
- err := login.conn.Modify(modify_request)
+ err := user.Login.conn.Modify(modify_request)
if err != nil {
dError = err.Error()
} else {
@@ -511,7 +511,7 @@ func handleAdminLDAP(w http.ResponseWriter, r *http.Request) {
modify_request := ldap.NewModifyRequest(dn, nil)
modify_request.Delete("member", []string{member})
- err := login.conn.Modify(modify_request)
+ err := user.Login.conn.Modify(modify_request)
if err != nil {
dError = err.Error()
} else {
@@ -519,7 +519,7 @@ func handleAdminLDAP(w http.ResponseWriter, r *http.Request) {
}
} else if action == "delete-object" {
del_request := ldap.NewDelRequest(dn, nil)
- err := login.conn.Del(del_request)
+ err := user.Login.conn.Del(del_request)
if err != nil {
dError = err.Error()
} else {
@@ -537,7 +537,7 @@ func handleAdminLDAP(w http.ResponseWriter, r *http.Request) {
[]string{},
nil)
- sr, err := login.conn.Search(searchRequest)
+ sr, err := user.Login.conn.Search(searchRequest)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -621,7 +621,7 @@ func handleAdminLDAP(w http.ResponseWriter, r *http.Request) {
fmt.Sprintf("(objectClass=organizationalPerson)"),
[]string{"dn", "displayname", "description"},
nil)
- sr, err = login.conn.Search(searchRequest)
+ sr, err = user.Login.conn.Search(searchRequest)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -675,7 +675,7 @@ func handleAdminLDAP(w http.ResponseWriter, r *http.Request) {
fmt.Sprintf("(objectClass=groupOfNames)"),
[]string{"dn", "description"},
nil)
- sr, err = login.conn.Search(searchRequest)
+ sr, err = user.Login.conn.Search(searchRequest)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -719,7 +719,7 @@ func handleAdminLDAP(w http.ResponseWriter, r *http.Request) {
[]string{"dn", "displayname", "description"},
nil)
- sr, err = login.conn.Search(searchRequest)
+ sr, err = user.Login.conn.Search(searchRequest)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -787,8 +787,8 @@ type CreateData struct {
func handleAdminCreate(w http.ResponseWriter, r *http.Request) {
templateAdminCreate := getTemplate("admin_create.html")
- login := checkAdminLogin(w, r)
- if login == nil {
+ user := checkAdminLogin(w, r)
+ if user == nil {
return
}
@@ -803,7 +803,7 @@ func handleAdminCreate(w http.ResponseWriter, r *http.Request) {
[]string{},
nil)
- sr, err := login.conn.Search(searchRequest)
+ sr, err := user.Login.conn.Search(searchRequest)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -894,7 +894,7 @@ func handleAdminCreate(w http.ResponseWriter, r *http.Request) {
req.Attribute("description", []string{data.Description})
}
- err := login.conn.Add(req)
+ err := user.Login.conn.Add(req)
if err != nil {
data.Error = err.Error()
} else {
diff --git a/api.go b/api.go
new file mode 100644
index 0000000..e99fce5
--- /dev/null
+++ b/api.go
@@ -0,0 +1,127 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "github.com/gorilla/mux"
+ "net/http"
+)
+
+func handleAPIWebsiteList(w http.ResponseWriter, r *http.Request) {
+ user := RequireUserApi(w, r)
+
+ if user == nil {
+ return
+ }
+
+ ctrl, err := NewWebsiteController(user)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if r.Method == http.MethodGet {
+ describe, err := ctrl.Describe()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(describe)
+ return
+ }
+
+ http.Error(w, "This method is not implemented for this endpoint", http.StatusNotImplemented)
+ return
+}
+
+func handleAPIWebsiteInspect(w http.ResponseWriter, r *http.Request) {
+ user := RequireUserApi(w, r)
+
+ if user == nil {
+ return
+ }
+
+ bucketName := mux.Vars(r)["bucket"]
+ ctrl, err := NewWebsiteController(user)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if r.Method == http.MethodGet {
+ view, err := ctrl.Inspect(bucketName)
+ if errors.Is(err, ErrWebsiteNotFound) {
+ http.Error(w, err.Error(), http.StatusNotFound)
+ return
+ } else if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(view)
+ return
+ }
+
+ if r.Method == http.MethodPost {
+ view, err := ctrl.Create(bucketName)
+ if errors.Is(err, ErrEmptyBucketName) {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ } else if errors.Is(err, ErrWebsiteQuotaReached) {
+ http.Error(w, err.Error(), http.StatusForbidden)
+ return
+ } else if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusCreated)
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(view)
+ return
+ }
+
+ if r.Method == http.MethodPatch {
+ var patch WebsitePatch
+ err := json.NewDecoder(r.Body).Decode(&patch)
+ if err != nil {
+ http.Error(w, errors.Join(fmt.Errorf("Can't parse the request body as a website patch JSON"), err).Error(), http.StatusBadRequest)
+ return
+ }
+
+ view, err := ctrl.Patch(bucketName, &patch)
+ if errors.Is(err, ErrWebsiteNotFound) {
+ http.Error(w, err.Error(), http.StatusNotFound)
+ return
+ } else if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(view)
+ return
+ }
+
+ if r.Method == http.MethodDelete {
+ err := ctrl.Delete(bucketName)
+ if errors.Is(err, ErrEmptyBucketName) || errors.Is(err, ErrBucketDeleteNotEmpty) || errors.Is(err, ErrBucketDeleteUnfinishedUpload) {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ } else if errors.Is(err, ErrWebsiteNotFound) {
+ http.Error(w, err.Error(), http.StatusNotFound)
+ return
+ } else if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ return
+
+ }
+
+ http.Error(w, "This method is not implemented for this endpoint", http.StatusNotImplemented)
+ return
+}
diff --git a/cli.go b/cli.go
new file mode 100644
index 0000000..2d45a4c
--- /dev/null
+++ b/cli.go
@@ -0,0 +1,44 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "golang.org/x/term"
+ "os"
+ "syscall"
+)
+
+var fsCli = flag.NewFlagSet("cli", flag.ContinueOnError)
+var passFlag = fsCli.Bool("passwd", false, "Tool to generate a guichet-compatible password hash")
+
+func cliMain(args []string) {
+ if err := fsCli.Parse(args); err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+
+ if *passFlag {
+ cliPasswd()
+ } else {
+ fsCli.PrintDefaults()
+ os.Exit(1)
+ }
+}
+
+func cliPasswd() {
+ fmt.Print("Password: ")
+ bytepw, err := term.ReadPassword(int(syscall.Stdin))
+ if err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+ pass := string(bytepw)
+
+ hash, err := SSHAEncode(pass)
+ if err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+
+ fmt.Println(hash)
+}
diff --git a/directory.go b/directory.go
index 0b5acd5..c7520f9 100644
--- a/directory.go
+++ b/directory.go
@@ -15,8 +15,8 @@ const FIELD_NAME_DIRECTORY_VISIBILITY = "directoryVisibility"
func handleDirectory(w http.ResponseWriter, r *http.Request) {
templateDirectory := getTemplate("directory.html")
- login := checkLogin(w, r)
- if login == nil {
+ user := RequireUserHtml(w, r)
+ if user == nil {
return
}
@@ -49,8 +49,8 @@ func handleDirectorySearch(w http.ResponseWriter, r *http.Request) {
}
//Log to allow the research
- login := checkLogin(w, r)
- if login == nil {
+ user := RequireUserHtml(w, r)
+ if user == nil {
http.Error(w, "Login required", http.StatusUnauthorized)
return
}
@@ -69,7 +69,7 @@ func handleDirectorySearch(w http.ResponseWriter, r *http.Request) {
},
nil)
- sr, err := login.conn.Search(searchRequest)
+ sr, err := user.Login.conn.Search(searchRequest)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
diff --git a/garage.go b/garage.go
index 1ae02e4..c43fd5f 100644
--- a/garage.go
+++ b/garage.go
@@ -2,10 +2,8 @@ package main
import (
"context"
- "errors"
"fmt"
garage "git.deuxfleurs.fr/garage-sdk/garage-admin-sdk-golang"
- "github.com/go-ldap/ldap/v3"
"github.com/gorilla/mux"
"log"
"net/http"
@@ -48,7 +46,7 @@ func grgGetKey(accessKey string) (*garage.KeyInfo, error) {
return resp, nil
}
-func grgCreateWebsite(gkey, bucket string) (*garage.BucketInfo, error) {
+func grgCreateBucket(bucket string) (*garage.BucketInfo, error) {
client, ctx := gadmin()
br := garage.NewCreateBucketRequest()
@@ -60,34 +58,40 @@ func grgCreateWebsite(gkey, bucket string) (*garage.BucketInfo, error) {
fmt.Printf("%+v\n", err)
return nil, err
}
+ return binfo, nil
+}
+
+func grgAllowKeyOnBucket(bid, gkey string) (*garage.BucketInfo, error) {
+ client, ctx := gadmin()
// Allow user's key
ar := garage.AllowBucketKeyRequest{
- BucketId: *binfo.Id,
+ BucketId: bid,
AccessKeyId: gkey,
Permissions: *garage.NewAllowBucketKeyRequestPermissions(true, true, true),
}
- binfo, _, err = client.BucketApi.AllowBucketKey(ctx).AllowBucketKeyRequest(ar).Execute()
+ binfo, _, err := client.BucketApi.AllowBucketKey(ctx).AllowBucketKeyRequest(ar).Execute()
if err != nil {
fmt.Printf("%+v\n", err)
return nil, err
}
- // Expose website and set quota
+ return binfo, nil
+}
+
+func allowWebsiteDefault() *garage.UpdateBucketRequestWebsiteAccess {
wr := garage.NewUpdateBucketRequestWebsiteAccess()
wr.SetEnabled(true)
wr.SetIndexDocument("index.html")
wr.SetErrorDocument("error.html")
- qr := garage.NewUpdateBucketRequestQuotas()
- qr.SetMaxSize(1024 * 1024 * 50) // 50MB
- qr.SetMaxObjects(10000) //10k objects
+ return wr
+}
- ur := garage.NewUpdateBucketRequest()
- ur.SetWebsiteAccess(*wr)
- ur.SetQuotas(*qr)
+func grgUpdateBucket(bid string, ur *garage.UpdateBucketRequest) (*garage.BucketInfo, error) {
+ client, ctx := gadmin()
- binfo, _, err = client.BucketApi.UpdateBucket(ctx, *binfo.Id).UpdateBucketRequest(*ur).Execute()
+ binfo, _, err := client.BucketApi.UpdateBucket(ctx, bid).UpdateBucketRequest(*ur).Execute()
if err != nil {
fmt.Printf("%+v\n", err)
return nil, err
@@ -97,155 +101,197 @@ func grgCreateWebsite(gkey, bucket string) (*garage.BucketInfo, error) {
return binfo, nil
}
-func grgGetBucket(bid string) (*garage.BucketInfo, error) {
+func grgAddGlobalAlias(bid, alias string) (*garage.BucketInfo, error) {
client, ctx := gadmin()
- resp, _, err := client.BucketApi.GetBucketInfo(ctx, bid).Execute()
+ resp, _, err := client.BucketApi.PutBucketGlobalAlias(ctx).Id(bid).Alias(alias).Execute()
if err != nil {
- fmt.Printf("%+v\n", err)
+ log.Println(err)
return nil, err
}
return resp, nil
+}
+func grgAddLocalAlias(bid, key, alias string) (*garage.BucketInfo, error) {
+ client, ctx := gadmin()
+
+ resp, _, err := client.BucketApi.PutBucketLocalAlias(ctx).Id(bid).AccessKeyId(key).Alias(alias).Execute()
+ if err != nil {
+ log.Println(err)
+ return nil, err
+ }
+ return resp, nil
}
-func checkLoginAndS3(w http.ResponseWriter, r *http.Request) (*LoginStatus, *garage.KeyInfo, error) {
- login := checkLogin(w, r)
- if login == nil {
- return nil, nil, errors.New("LDAP login failed")
+func grgDelGlobalAlias(bid, alias string) (*garage.BucketInfo, error) {
+ client, ctx := gadmin()
+
+ resp, _, err := client.BucketApi.DeleteBucketGlobalAlias(ctx).Id(bid).Alias(alias).Execute()
+ if err != nil {
+ log.Println(err)
+ return nil, err
}
+ return resp, nil
+}
- keyID := login.UserEntry.GetAttributeValue("garage_s3_access_key")
- if keyID == "" {
- keyPair, err := grgCreateKey(login.Info.Username)
- if err != nil {
- return login, nil, err
- }
- modify_request := ldap.NewModifyRequest(login.Info.DN, nil)
- modify_request.Replace("garage_s3_access_key", []string{*keyPair.AccessKeyId})
- // @FIXME compatibility feature for bagage (SFTP+webdav)
- // you can remove it once bagage will be updated to fetch the key from garage directly
- // or when bottin will be able to dynamically fetch it.
- modify_request.Replace("garage_s3_secret_key", []string{*keyPair.SecretAccessKey})
- err = login.conn.Modify(modify_request)
- return login, keyPair, err
+func grgDelLocalAlias(bid, key, alias string) (*garage.BucketInfo, error) {
+ client, ctx := gadmin()
+
+ resp, _, err := client.BucketApi.DeleteBucketLocalAlias(ctx).Id(bid).AccessKeyId(key).Alias(alias).Execute()
+ if err != nil {
+ log.Println(err)
+ return nil, err
}
- // Note: we could simply return the login info, but LX asked we do not
- // store the secrets in LDAP in the future.
- keyPair, err := grgGetKey(keyID)
- return login, keyPair, err
+ return resp, nil
}
-type keyView struct {
- Status *LoginStatus
- Key *garage.KeyInfo
+func grgGetBucket(bid string) (*garage.BucketInfo, error) {
+ client, ctx := gadmin()
+
+ resp, _, err := client.BucketApi.GetBucketInfo(ctx, bid).Execute()
+ if err != nil {
+ log.Println(err)
+ return nil, err
+ }
+ return resp, nil
+
}
-func handleGarageKey(w http.ResponseWriter, r *http.Request) {
- login, s3key, err := checkLoginAndS3(w, r)
+func grgDeleteBucket(bid string) error {
+ client, ctx := gadmin()
+
+ _, err := client.BucketApi.DeleteBucket(ctx, bid).Execute()
if err != nil {
log.Println(err)
+ }
+ return err
+}
+
+// --- Start page rendering functions
+
+func handleWebsiteConfigure(w http.ResponseWriter, r *http.Request) {
+ user := RequireUserHtml(w, r)
+ if user == nil {
return
}
- view := keyView{Status: login, Key: s3key}
tKey := getTemplate("garage_key.html")
- tKey.Execute(w, &view)
+ tKey.Execute(w, user)
}
-type webListView struct {
- Status *LoginStatus
- Key *garage.KeyInfo
-}
+func handleWebsiteList(w http.ResponseWriter, r *http.Request) {
+ user := RequireUserHtml(w, r)
+ if user == nil {
+ return
+ }
-func handleGarageWebsiteList(w http.ResponseWriter, r *http.Request) {
- login, s3key, err := checkLoginAndS3(w, r)
+ ctrl, err := NewWebsiteController(user)
if err != nil {
- log.Println(err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- view := webListView{Status: login, Key: s3key}
- tWebsiteList := getTemplate("garage_website_list.html")
- tWebsiteList.Execute(w, &view)
+ if len(ctrl.PrettyList) > 0 {
+ http.Redirect(w, r, "/website/inspect/"+ctrl.PrettyList[0], http.StatusFound)
+ } else {
+ http.Redirect(w, r, "/website/new", http.StatusFound)
+ }
+}
+
+type WebsiteNewTpl struct {
+ Ctrl *WebsiteController
+ Err error
}
-func handleGarageWebsiteNew(w http.ResponseWriter, r *http.Request) {
- _, s3key, err := checkLoginAndS3(w, r)
+func handleWebsiteNew(w http.ResponseWriter, r *http.Request) {
+ user := RequireUserHtml(w, r)
+ if user == nil {
+ return
+ }
+
+ ctrl, err := NewWebsiteController(user)
if err != nil {
- log.Println(err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
+ tpl := &WebsiteNewTpl{ctrl, nil}
+
tWebsiteNew := getTemplate("garage_website_new.html")
if r.Method == "POST" {
r.ParseForm()
- log.Println(r.Form)
bucket := strings.Join(r.Form["bucket"], "")
if bucket == "" {
bucket = strings.Join(r.Form["bucket2"], "")
}
- if bucket == "" {
- log.Println("Form empty")
- // @FIXME we need to return the error to the user
- tWebsiteNew.Execute(w, nil)
- return
- }
- binfo, err := grgCreateWebsite(*s3key.AccessKeyId, bucket)
+ view, err := ctrl.Create(bucket)
if err != nil {
- log.Println(err)
- // @FIXME we need to return the error to the user
- tWebsiteNew.Execute(w, nil)
+ tpl.Err = err
+ tWebsiteNew.Execute(w, tpl)
return
}
- http.Redirect(w, r, "/garage/website/b/"+*binfo.Id, http.StatusFound)
+ http.Redirect(w, r, "/website/inspect/"+view.Name.Pretty, http.StatusFound)
return
}
- tWebsiteNew.Execute(w, nil)
+ tWebsiteNew.Execute(w, tpl)
}
-type webInspectView struct {
- Status *LoginStatus
- Key *garage.KeyInfo
- Bucket *garage.BucketInfo
- IndexDoc string
- ErrorDoc string
- MaxObjects int64
- MaxSize int64
- UsedSizePct float64
+type WebsiteInspectTpl struct {
+ Describe *WebsiteDescribe
+ View *WebsiteView
+ Err error
}
-func handleGarageWebsiteInspect(w http.ResponseWriter, r *http.Request) {
- login, s3key, err := checkLoginAndS3(w, r)
- if err != nil {
- log.Println(err)
+func handleWebsiteInspect(w http.ResponseWriter, r *http.Request) {
+ var processErr error
+
+ user := RequireUserHtml(w, r)
+ if user == nil {
return
}
- bucketId := mux.Vars(r)["bucket"]
- binfo, err := grgGetBucket(bucketId)
+ ctrl, err := NewWebsiteController(user)
if err != nil {
- log.Println(err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- wc := binfo.GetWebsiteConfig()
- q := binfo.GetQuotas()
+ bucketName := mux.Vars(r)["bucket"]
- view := webInspectView{
- Status: login,
- Key: s3key,
- Bucket: binfo,
- IndexDoc: (&wc).GetIndexDocument(),
- ErrorDoc: (&wc).GetErrorDocument(),
- MaxObjects: (&q).GetMaxObjects(),
- MaxSize: (&q).GetMaxSize(),
+ if r.Method == "POST" {
+ r.ParseForm()
+ action := strings.Join(r.Form["action"], "")
+ switch action {
+ case "increase_quota":
+ _, processErr = ctrl.Patch(bucketName, &WebsitePatch{Size: &user.Quota.WebsiteSizeBursted})
+ case "delete_bucket":
+ processErr = ctrl.Delete(bucketName)
+ http.Redirect(w, r, "/website", http.StatusFound)
+ return
+ default:
+ processErr = fmt.Errorf("Unknown action")
+ }
+
+ }
+
+ view, err := ctrl.Inspect(bucketName)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
}
+ describe, err := ctrl.Describe()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ tpl := &WebsiteInspectTpl{describe, view, processErr}
+
tWebsiteInspect := getTemplate("garage_website_inspect.html")
- tWebsiteInspect.Execute(w, &view)
+ tWebsiteInspect.Execute(w, &tpl)
}
diff --git a/go.mod b/go.mod
index bacf791..56bd9f6 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,7 @@ require (
github.com/minio/minio-go/v7 v7.0.0
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
+ golang.org/x/term v0.12.0
)
require (
@@ -29,7 +30,7 @@ require (
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 // indirect
golang.org/x/net v0.0.0-20200822124328-c89045814202 // indirect
golang.org/x/oauth2 v0.0.0-20210323180902-22b0adad7558 // indirect
- golang.org/x/sys v0.0.0-20200803210538-64077c9b5642 // indirect
+ golang.org/x/sys v0.12.0 // indirect
golang.org/x/text v0.3.3 // indirect
google.golang.org/appengine v1.6.6 // indirect
google.golang.org/protobuf v1.25.0 // indirect
diff --git a/go.sum b/go.sum
index 3e2e72d..ae748fd 100644
--- a/go.sum
+++ b/go.sum
@@ -278,8 +278,11 @@ golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200803210538-64077c9b5642 h1:B6caxRw+hozq68X2MY7jEpZh/cr4/aHLv9xU8Kkadrw=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/integration/config/bottin.json b/integration/config/bottin.json
index 0b54e22..4b9f3d7 100644
--- a/integration/config/bottin.json
+++ b/integration/config/bottin.json
@@ -6,8 +6,14 @@
"ANONYMOUS::bind:*,ou=users,dc=bottin,dc=eu:",
"ANONYMOUS::bind:cn=admin,dc=bottin,dc=eu:",
"*,dc=bottin,dc=eu::read:*:* !userpassword",
- "*::read modify:SELF:*",
"cn=admin,dc=bottin,dc=eu::read add modify delete:*:*",
- "*:cn=admin,ou=groups,dc=bottin,dc=eu:read add modify delete:*:*"
+ "*:cn=admin,ou=groups,dc=bottin,dc=eu:read add modify delete:*:*",
+
+ "ANONYMOUS::bind:*,ou=invitations,dc=bottin,dc=eu:",
+ "*,ou=invitations,dc=bottin,dc=eu::delete:SELF:*",
+ "*,ou=invitations,dc=bottin,dc=eu::add:*,ou=users,dc=bottin,dc=eu:*",
+ "*,ou=invitations,dc=bottin,dc=eu::modifyAdd:cn=email,ou=groups,dc=bottin,dc=eu:*",
+
+ "*::read modify:SELF:*"
]
}
diff --git a/integration/docker-compose.yml b/integration/docker-compose.yml
index cf1c088..ec855db 100644
--- a/integration/docker-compose.yml
+++ b/integration/docker-compose.yml
@@ -1,19 +1,19 @@
version: '3'
services:
consul:
- image: consul
+ image: hashicorp/consul:1.16
restart: "always"
expose:
- 8500
bottin:
- image: dxflrs/bottin:dnp41vp8w24h4mbh0xg1mybzr1f46k41
- command: "-config /etc/bottin.json"
+ image: dxflrs/bottin:7h18i30cckckaahv87d3c86pn4a7q41z
+ #command: "-config /etc/bottin.json"
restart: "always"
depends_on: ["consul"]
ports:
- "389:389"
volumes:
- - "./config/bottin.json:/etc/bottin.json"
+ - "./config/bottin.json:/config.json"
garage:
image: dxflrs/garage:v0.8.2
ports:
diff --git a/invite.go b/invite.go
index 1384d70..060947a 100644
--- a/invite.go
+++ b/invite.go
@@ -21,29 +21,29 @@ import (
var EMAIL_REGEXP = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
-func checkInviterLogin(w http.ResponseWriter, r *http.Request) *LoginStatus {
- login := checkLogin(w, r)
- if login == nil {
+func checkInviterLogin(w http.ResponseWriter, r *http.Request) *LoggedUser {
+ user := RequireUserHtml(w, r)
+ if user == nil {
return nil
}
- if !login.CanInvite {
+ if !user.Capabilities.CanInvite {
http.Error(w, "Not authorized to invite new users.", http.StatusUnauthorized)
return nil
}
- return login
+ return user
}
// New account creation directly from interface
func handleInviteNewAccount(w http.ResponseWriter, r *http.Request) {
- login := checkInviterLogin(w, r)
- if login == nil {
+ user := checkInviterLogin(w, r)
+ if user == nil {
return
}
- handleNewAccount(w, r, login.conn, login.Info.DN)
+ handleNewAccount(w, r, user.Login.conn, user.Login.Info.DN())
}
// New account creation using code
@@ -52,14 +52,15 @@ func handleInvitationCode(w http.ResponseWriter, r *http.Request) {
code := mux.Vars(r)["code"]
code_id, code_pw := readCode(code)
- l := ldapOpen(w)
- if l == nil {
- return
+ l, err := NewLdapCon()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
}
inviteDn := config.InvitationNameAttr + "=" + code_id + "," + config.InvitationBaseDN
- err := l.Bind(inviteDn, code_pw)
+ err = l.Bind(inviteDn, code_pw)
if err != nil {
+ log.Println(err)
templateInviteInvalidCode := getTemplate("invite_invalid_code.html")
templateInviteInvalidCode.Execute(w, nil)
return
@@ -241,8 +242,8 @@ type CodeMailFields struct {
func handleInviteSendCode(w http.ResponseWriter, r *http.Request) {
templateInviteSendCode := getTemplate("invite_send_code.html")
- login := checkInviterLogin(w, r)
- if login == nil {
+ user := checkInviterLogin(w, r)
+ if user == nil {
return
}
@@ -257,14 +258,14 @@ func handleInviteSendCode(w http.ResponseWriter, r *http.Request) {
sendto := strings.Join(r.Form["sendto"], "")
if choice == "display" || choice == "send" {
- trySendCode(login, choice, sendto, data)
+ trySendCode(user, choice, sendto, data)
}
}
templateInviteSendCode.Execute(w, data)
}
-func trySendCode(login *LoginStatus, choice string, sendto string, data *SendCodeData) {
+func trySendCode(user *LoggedUser, choice string, sendto string, data *SendCodeData) {
// Generate code
code, code_id, code_pw := genCode()
@@ -279,7 +280,7 @@ func trySendCode(login *LoginStatus, choice string, sendto string, data *SendCod
req.Attribute("userpassword", []string{pw})
req.Attribute("objectclass", []string{"top", "invitationCode"})
- err = login.conn.Add(req)
+ err = user.Login.conn.Add(req)
if err != nil {
data.ErrorMessage = err.Error()
return
@@ -303,7 +304,7 @@ func trySendCode(login *LoginStatus, choice string, sendto string, data *SendCod
templateMail.Execute(buf, &CodeMailFields{
To: sendto,
From: config.MailFrom,
- InviteFrom: login.WelcomeName(),
+ InviteFrom: user.WelcomeName(),
Code: code,
WebBaseAddress: config.WebAddress,
})
diff --git a/login.go b/login.go
new file mode 100644
index 0000000..277e3ae
--- /dev/null
+++ b/login.go
@@ -0,0 +1,294 @@
+package main
+
+import (
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ garage "git.deuxfleurs.fr/garage-sdk/garage-admin-sdk-golang"
+ "github.com/go-ldap/ldap/v3"
+)
+
+var (
+ ErrNotAuthenticatedSession = fmt.Errorf("User has no session")
+ ErrNotAuthenticatedBasic = fmt.Errorf("User has not sent Authentication Basic information")
+ ErrNotAuthenticated = fmt.Errorf("User is not authenticated")
+ ErrWrongLDAPCredentials = fmt.Errorf("LDAP credentials are wrong")
+ ErrLDAPServerUnreachable = fmt.Errorf("Unable to open the LDAP server")
+ ErrLDAPSearchInternalError = fmt.Errorf("LDAP Search of this user failed with an internal error")
+ ErrLDAPSearchNotFound = fmt.Errorf("User is authenticated but its associated data can not be found during search")
+)
+
+// --- Login Info ---
+type LoginInfo struct {
+ Username string
+ Password string
+}
+
+func NewLoginInfoFromSession(r *http.Request) (*LoginInfo, error) {
+ session, err := store.Get(r, SESSION_NAME)
+ if err == nil {
+ username, ok_user := session.Values["login_username"]
+ password, ok_pwd := session.Values["login_password"]
+
+ if ok_user && ok_pwd {
+ loginInfo := &LoginInfo{
+ Username: username.(string),
+ Password: password.(string),
+ }
+ return loginInfo, nil
+ }
+ }
+
+ return nil, errors.Join(ErrNotAuthenticatedSession, err)
+}
+
+func NewLoginInfoFromBasicAuth(r *http.Request) (*LoginInfo, error) {
+ username, password, ok := r.BasicAuth()
+ if ok {
+ login_info := &LoginInfo{
+ Username: username,
+ Password: password,
+ }
+
+ return login_info, nil
+ }
+ return nil, ErrNotAuthenticatedBasic
+}
+
+func (li *LoginInfo) DN() string {
+ user_dn := fmt.Sprintf("%s=%s,%s", config.UserNameAttr, li.Username, config.UserBaseDN)
+ if strings.EqualFold(li.Username, config.AdminAccount) {
+ user_dn = li.Username
+ }
+
+ return user_dn
+}
+
+// --- Login Status ---
+type LoginStatus struct {
+ Info *LoginInfo
+ conn *ldap.Conn
+}
+
+func NewLoginStatus(r *http.Request, login_info *LoginInfo) (*LoginStatus, error) {
+ l, err := NewLdapCon()
+ if err != nil {
+ return nil, err
+ }
+
+ err = l.Bind(login_info.DN(), login_info.Password)
+ if err != nil {
+ return nil, errors.Join(ErrWrongLDAPCredentials, err)
+ }
+
+ loginStatus := &LoginStatus{
+ Info: login_info,
+ conn: l,
+ }
+ return loginStatus, nil
+}
+
+func NewLdapCon() (*ldap.Conn, error) {
+ l, err := ldap.DialURL(config.LdapServerAddr)
+ if err != nil {
+ return nil, errors.Join(ErrLDAPServerUnreachable, err)
+ }
+
+ if config.LdapTLS {
+ err = l.StartTLS(&tls.Config{InsecureSkipVerify: true})
+ if err != nil {
+ return nil, errors.Join(ErrLDAPServerUnreachable, err)
+ }
+ }
+
+ return l, nil
+}
+
+// --- Capabilities ---
+type Capabilities struct {
+ CanAdmin bool
+ CanInvite bool
+}
+
+func NewCapabilities(login *LoginStatus, entry *ldap.Entry) *Capabilities {
+ // Initialize
+ canAdmin := false
+ canInvite := false
+
+ // Special case for the "admin" account that is de-facto admin
+ canAdmin = strings.EqualFold(login.Info.DN(), config.AdminAccount)
+
+ // Check if this account is part of a group that give capabilities
+ for _, attr := range entry.Attributes {
+ if strings.EqualFold(attr.Name, "memberof") {
+ for _, group := range attr.Values {
+ if config.GroupCanInvite != "" && strings.EqualFold(group, config.GroupCanInvite) {
+ canInvite = true
+ }
+ if config.GroupCanAdmin != "" && strings.EqualFold(group, config.GroupCanAdmin) {
+ canAdmin = true
+ }
+ }
+ }
+ }
+
+ return &Capabilities{
+ CanAdmin: canAdmin,
+ CanInvite: canInvite,
+ }
+}
+
+// --- Logged User ---
+type LoggedUser struct {
+ Login *LoginStatus
+ Entry *ldap.Entry
+ Capabilities *Capabilities
+ Quota *UserQuota
+ s3key *garage.KeyInfo
+}
+
+func NewLoggedUser(login *LoginStatus) (*LoggedUser, error) {
+ requestKind := "(objectClass=organizationalPerson)"
+ if strings.EqualFold(login.Info.DN(), config.AdminAccount) {
+ requestKind = "(objectclass=*)"
+ }
+
+ searchRequest := ldap.NewSearchRequest(
+ login.Info.DN(),
+ ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false,
+ requestKind,
+ []string{
+ "dn",
+ "displayname",
+ "givenname",
+ "sn",
+ "mail",
+ "memberof",
+ "description",
+ "garage_s3_access_key",
+ FIELD_NAME_DIRECTORY_VISIBILITY,
+ FIELD_NAME_PROFILE_PICTURE,
+ FIELD_QUOTA_WEBSITE_SIZE_BURSTED,
+ FIELD_QUOTA_WEBSITE_COUNT,
+ },
+ nil)
+
+ sr, err := login.conn.Search(searchRequest)
+ if err != nil {
+ return nil, ErrLDAPSearchInternalError
+ }
+
+ if len(sr.Entries) != 1 {
+ return nil, ErrLDAPSearchNotFound
+ }
+ entry := sr.Entries[0]
+
+ lu := &LoggedUser{
+ Login: login,
+ Entry: entry,
+ Capabilities: NewCapabilities(login, entry),
+ Quota: NewUserQuotaFromEntry(entry),
+ }
+ return lu, nil
+}
+func (lu *LoggedUser) WelcomeName() string {
+ ret := lu.Entry.GetAttributeValue("givenname")
+ if ret == "" {
+ ret = lu.Entry.GetAttributeValue("displayname")
+ }
+ if ret == "" {
+ ret = lu.Login.Info.Username
+ }
+ return ret
+}
+func (lu *LoggedUser) S3KeyInfo() (*garage.KeyInfo, error) {
+ var err error
+ var keyPair *garage.KeyInfo
+
+ if lu.s3key == nil {
+ keyID := lu.Entry.GetAttributeValue("garage_s3_access_key")
+ if keyID == "" {
+ // If there is no S3Key in LDAP, generate it...
+ keyPair, err = grgCreateKey(lu.Login.Info.Username)
+ if err != nil {
+ return nil, err
+ }
+ modify_request := ldap.NewModifyRequest(lu.Login.Info.DN(), nil)
+ modify_request.Replace("garage_s3_access_key", []string{*keyPair.AccessKeyId})
+ // @FIXME compatibility feature for bagage (SFTP+webdav)
+ // you can remove it once bagage will be updated to fetch the key from garage directly
+ // or when bottin will be able to dynamically fetch it.
+ modify_request.Replace("garage_s3_secret_key", []string{*keyPair.SecretAccessKey})
+ err = lu.Login.conn.Modify(modify_request)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ // There is an S3 key in LDAP, fetch its descriptor...
+ keyPair, err = grgGetKey(keyID)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ // Cache the keypair...
+ lu.s3key = keyPair
+ }
+
+ return lu.s3key, nil
+}
+
+// --- Require User Check
+func RequireUser(r *http.Request) (*LoggedUser, error) {
+ var login_info *LoginInfo
+
+ if li, err := NewLoginInfoFromSession(r); err == nil {
+ login_info = li
+ } else if li, err := NewLoginInfoFromBasicAuth(r); err == nil {
+ login_info = li
+ } else {
+ return nil, ErrNotAuthenticated
+ }
+
+ loginStatus, err := NewLoginStatus(r, login_info)
+ if err != nil {
+ return nil, err
+ }
+
+ return NewLoggedUser(loginStatus)
+}
+
+func RequireUserHtml(w http.ResponseWriter, r *http.Request) *LoggedUser {
+ user, err := RequireUser(r)
+
+ if errors.Is(err, ErrNotAuthenticated) || errors.Is(err, ErrWrongLDAPCredentials) {
+ http.Redirect(w, r, "/login", http.StatusFound)
+ return nil
+ }
+
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return nil
+ }
+
+ return user
+}
+
+func RequireUserApi(w http.ResponseWriter, r *http.Request) *LoggedUser {
+ user, err := RequireUser(r)
+
+ if errors.Is(err, ErrNotAuthenticated) || errors.Is(err, ErrWrongLDAPCredentials) {
+ http.Error(w, err.Error(), http.StatusUnauthorized)
+ return nil
+ }
+
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return nil
+ }
+
+ return user
+}
diff --git a/main.go b/main.go
index ae8fe06..6553bef 100644
--- a/main.go
+++ b/main.go
@@ -2,10 +2,8 @@ package main
import (
"crypto/rand"
- "crypto/tls"
"encoding/json"
"flag"
- "fmt"
"html/template"
"io/ioutil"
"log"
@@ -58,7 +56,8 @@ type ConfigFile struct {
S3Bucket string `json:"s3_bucket"`
}
-var configFlag = flag.String("config", "./config.json", "Configuration file path")
+var fsServer = flag.NewFlagSet("server", flag.ContinueOnError)
+var configFlag = fsServer.String("config", "./config.json", "Configuration file path")
var config *ConfigFile
@@ -114,8 +113,25 @@ func getTemplate(name string) *template.Template {
}
func main() {
- flag.Parse()
+ if len(os.Args) < 2 {
+ server(os.Args[1:])
+ return
+ }
+
+ switch os.Args[1] {
+ case "cli":
+ cliMain(os.Args[2:])
+ case "server":
+ server(os.Args[2:])
+ default:
+ log.Println("Usage: guichet [server|cli] --help")
+ os.Exit(1)
+ }
+}
+func server(args []string) {
+ log.Println("Starting Guichet Server")
+ fsServer.Parse(args)
config_file := readConfig()
config = &config_file
@@ -128,8 +144,12 @@ func main() {
r := mux.NewRouter()
r.HandleFunc("/", handleHome)
+ r.HandleFunc("/login", handleLogin)
r.HandleFunc("/logout", handleLogout)
+ r.HandleFunc("/api/unstable/website", handleAPIWebsiteList)
+ r.HandleFunc("/api/unstable/website/{bucket}", handleAPIWebsiteInspect)
+
r.HandleFunc("/profile", handleProfile)
r.HandleFunc("/passwd", handlePasswd)
r.HandleFunc("/picture/{name}", handleDownloadPicture)
@@ -137,10 +157,10 @@ func main() {
r.HandleFunc("/directory/search", handleDirectorySearch)
r.HandleFunc("/directory", handleDirectory)
- r.HandleFunc("/garage/key", handleGarageKey)
- r.HandleFunc("/garage/website", handleGarageWebsiteList)
- r.HandleFunc("/garage/website/new", handleGarageWebsiteNew)
- r.HandleFunc("/garage/website/b/{bucket}", handleGarageWebsiteInspect)
+ r.HandleFunc("/website", handleWebsiteList)
+ r.HandleFunc("/website/new", handleWebsiteNew)
+ r.HandleFunc("/website/configure", handleWebsiteConfigure)
+ r.HandleFunc("/website/inspect/{bucket}", handleWebsiteInspect)
r.HandleFunc("/invite/new_account", handleInviteNewAccount)
r.HandleFunc("/invite/send_code", handleInviteSendCode)
@@ -163,31 +183,6 @@ func main() {
}
}
-type LoginInfo struct {
- Username string
- DN string
- Password string
-}
-
-type LoginStatus struct {
- Info *LoginInfo
- conn *ldap.Conn
- UserEntry *ldap.Entry
- CanAdmin bool
- CanInvite bool
-}
-
-func (login *LoginStatus) WelcomeName() string {
- ret := login.UserEntry.GetAttributeValue("givenname")
- if ret == "" {
- ret = login.UserEntry.GetAttributeValue("displayname")
- }
- if ret == "" {
- ret = login.Info.Username
- }
- return ret
-}
-
func logRequest(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s %s\n", r.RemoteAddr, r.Method, r.URL)
@@ -195,149 +190,31 @@ func logRequest(handler http.Handler) http.Handler {
})
}
-func checkLogin(w http.ResponseWriter, r *http.Request) *LoginStatus {
- var login_info *LoginInfo
-
- session, err := store.Get(r, SESSION_NAME)
- if err == nil {
- username, ok := session.Values["login_username"]
- password, ok2 := session.Values["login_password"]
- user_dn, ok3 := session.Values["login_dn"]
-
- if ok && ok2 && ok3 {
- login_info = &LoginInfo{
- DN: user_dn.(string),
- Username: username.(string),
- Password: password.(string),
- }
- }
- }
-
- if login_info == nil {
- login_info = handleLogin(w, r)
- if login_info == nil {
- return nil
- }
- }
-
- l := ldapOpen(w)
- if l == nil {
- return nil
- }
-
- err = l.Bind(login_info.DN, login_info.Password)
- if err != nil {
- delete(session.Values, "login_username")
- delete(session.Values, "login_password")
- delete(session.Values, "login_dn")
-
- err = session.Save(r, w)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return nil
- }
- return checkLogin(w, r)
- }
-
- loginStatus := &LoginStatus{
- Info: login_info,
- conn: l,
- }
-
- requestKind := "(objectClass=organizationalPerson)"
- if strings.EqualFold(login_info.DN, config.AdminAccount) {
- requestKind = "(objectclass=*)"
- }
- searchRequest := ldap.NewSearchRequest(
- login_info.DN,
- ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false,
- requestKind,
- []string{
- "dn",
- "displayname",
- "givenname",
- "sn",
- "mail",
- "memberof",
- "description",
- "garage_s3_access_key",
- FIELD_NAME_DIRECTORY_VISIBILITY,
- FIELD_NAME_PROFILE_PICTURE,
- },
- nil)
-
- sr, err := l.Search(searchRequest)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return nil
- }
-
- if len(sr.Entries) != 1 {
- http.Error(w, fmt.Sprintf("Unable to find entry for %s", login_info.DN), http.StatusInternalServerError)
- return nil
- }
-
- loginStatus.UserEntry = sr.Entries[0]
-
- loginStatus.CanAdmin = strings.EqualFold(loginStatus.Info.DN, config.AdminAccount)
- loginStatus.CanInvite = false
- for _, attr := range loginStatus.UserEntry.Attributes {
- if strings.EqualFold(attr.Name, "memberof") {
- for _, group := range attr.Values {
- if config.GroupCanInvite != "" && strings.EqualFold(group, config.GroupCanInvite) {
- loginStatus.CanInvite = true
- }
- if config.GroupCanAdmin != "" && strings.EqualFold(group, config.GroupCanAdmin) {
- loginStatus.CanAdmin = true
- }
- }
- }
- }
-
- return loginStatus
-}
-
-func ldapOpen(w http.ResponseWriter) *ldap.Conn {
- l, err := ldap.DialURL(config.LdapServerAddr)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return nil
- }
-
- if config.LdapTLS {
- err = l.StartTLS(&tls.Config{InsecureSkipVerify: true})
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return nil
- }
- }
-
- return l
-}
-
// Page handlers ----
+// --- Home Controller
type HomePageData struct {
- Login *LoginStatus
+ User *LoggedUser
BaseDN string
}
func handleHome(w http.ResponseWriter, r *http.Request) {
templateHome := getTemplate("home.html")
- login := checkLogin(w, r)
- if login == nil {
+ user := RequireUserHtml(w, r)
+ if user == nil {
return
}
data := &HomePageData{
- Login: login,
+ User: user,
BaseDN: config.BaseDN,
}
templateHome.Execute(w, data)
}
+// --- Logout Controller
func handleLogout(w http.ResponseWriter, r *http.Request) {
session, err := store.Get(r, SESSION_NAME)
if err != nil {
@@ -354,9 +231,10 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
return
}
- http.Redirect(w, r, "/", http.StatusFound)
+ http.Redirect(w, r, "/login", http.StatusFound)
}
+// --- Login Controller ---
type LoginFormData struct {
Username string
WrongUser bool
@@ -364,28 +242,26 @@ type LoginFormData struct {
ErrorMessage string
}
-func handleLogin(w http.ResponseWriter, r *http.Request) *LoginInfo {
+func handleLogin(w http.ResponseWriter, r *http.Request) {
templateLogin := getTemplate("login.html")
if r.Method == "GET" {
templateLogin.Execute(w, LoginFormData{})
- return nil
+ return
} else if r.Method == "POST" {
r.ParseForm()
username := strings.Join(r.Form["username"], "")
password := strings.Join(r.Form["password"], "")
- user_dn := fmt.Sprintf("%s=%s,%s", config.UserNameAttr, username, config.UserBaseDN)
- if strings.EqualFold(username, config.AdminAccount) {
- user_dn = username
- }
+ loginInfo := LoginInfo{username, password}
- l := ldapOpen(w)
- if l == nil {
- return nil
+ l, err := NewLdapCon()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
}
- err := l.Bind(user_dn, password)
+ err = l.Bind(loginInfo.DN(), loginInfo.Password)
if err != nil {
data := &LoginFormData{
Username: username,
@@ -398,7 +274,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) *LoginInfo {
data.ErrorMessage = err.Error()
}
templateLogin.Execute(w, data)
- return nil
+ return
}
// Successfully logged in, save it to session
@@ -409,21 +285,15 @@ func handleLogin(w http.ResponseWriter, r *http.Request) *LoginInfo {
session.Values["login_username"] = username
session.Values["login_password"] = password
- session.Values["login_dn"] = user_dn
err = session.Save(r, w)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
- return nil
+ return
}
- return &LoginInfo{
- DN: user_dn,
- Username: username,
- Password: password,
- }
+ http.Redirect(w, r, "/", http.StatusFound)
} else {
http.Error(w, "Unsupported method", http.StatusBadRequest)
- return nil
}
}
diff --git a/picture.go b/picture.go
index 877ba05..005230d 100644
--- a/picture.go
+++ b/picture.go
@@ -44,7 +44,7 @@ func newMinioClient() (*minio.Client, error) {
}
// Upload image through guichet server.
-func uploadProfilePicture(w http.ResponseWriter, r *http.Request, login *LoginStatus) (string, error) {
+func uploadProfilePicture(w http.ResponseWriter, r *http.Request, user *LoggedUser) (string, error) {
file, _, err := r.FormFile("image")
if err == http.ErrMissingFile {
@@ -74,7 +74,7 @@ func uploadProfilePicture(w http.ResponseWriter, r *http.Request, login *LoginSt
// If a previous profile picture existed, delete it
// (don't care about errors)
- if nameConsul := login.UserEntry.GetAttributeValue(FIELD_NAME_PROFILE_PICTURE); nameConsul != "" {
+ if nameConsul := user.Entry.GetAttributeValue(FIELD_NAME_PROFILE_PICTURE); nameConsul != "" {
mc.RemoveObject(context.Background(), config.S3Bucket, nameConsul, minio.RemoveObjectOptions{})
mc.RemoveObject(context.Background(), config.S3Bucket, nameConsul+"-thumb", minio.RemoveObjectOptions{})
}
@@ -144,9 +144,9 @@ func resizePicture(file multipart.File, buffFull, buffThumb *bytes.Buffer) error
func handleDownloadPicture(w http.ResponseWriter, r *http.Request) {
name := mux.Vars(r)["name"]
- //Check login
- login := checkLogin(w, r)
- if login == nil {
+ // Get user
+ user := RequireUserHtml(w, r)
+ if user == nil {
return
}
diff --git a/profile.go b/profile.go
index a082ad8..bd7e299 100644
--- a/profile.go
+++ b/profile.go
@@ -8,7 +8,7 @@ import (
)
type ProfileTplData struct {
- Status *LoginStatus
+ User *LoggedUser
ErrorMessage string
Success bool
Mail string
@@ -23,24 +23,24 @@ type ProfileTplData struct {
func handleProfile(w http.ResponseWriter, r *http.Request) {
templateProfile := getTemplate("profile.html")
- login := checkLogin(w, r)
- if login == nil {
+ user := RequireUserHtml(w, r)
+ if user == nil {
return
}
data := &ProfileTplData{
- Status: login,
+ User: user,
ErrorMessage: "",
Success: false,
}
- data.Mail = login.UserEntry.GetAttributeValue("mail")
- data.DisplayName = login.UserEntry.GetAttributeValue("displayname")
- data.GivenName = login.UserEntry.GetAttributeValue("givenname")
- data.Surname = login.UserEntry.GetAttributeValue("sn")
- data.Visibility = login.UserEntry.GetAttributeValue(FIELD_NAME_DIRECTORY_VISIBILITY)
- data.Description = login.UserEntry.GetAttributeValue("description")
- data.ProfilePicture = login.UserEntry.GetAttributeValue(FIELD_NAME_PROFILE_PICTURE)
+ data.Mail = user.Entry.GetAttributeValue("mail")
+ data.DisplayName = user.Entry.GetAttributeValue("displayname")
+ data.GivenName = user.Entry.GetAttributeValue("givenname")
+ data.Surname = user.Entry.GetAttributeValue("sn")
+ data.Visibility = user.Entry.GetAttributeValue(FIELD_NAME_DIRECTORY_VISIBILITY)
+ data.Description = user.Entry.GetAttributeValue("description")
+ data.ProfilePicture = user.Entry.GetAttributeValue(FIELD_NAME_PROFILE_PICTURE)
if r.Method == "POST" {
//5MB maximum size files
@@ -56,7 +56,7 @@ func handleProfile(w http.ResponseWriter, r *http.Request) {
}
data.Visibility = visible
- profilePicture, err := uploadProfilePicture(w, r, login)
+ profilePicture, err := uploadProfilePicture(w, r, user)
if err != nil {
data.ErrorMessage = err.Error()
}
@@ -65,7 +65,7 @@ func handleProfile(w http.ResponseWriter, r *http.Request) {
data.ProfilePicture = profilePicture
}
- modify_request := ldap.NewModifyRequest(login.Info.DN, nil)
+ modify_request := ldap.NewModifyRequest(user.Login.Info.DN(), nil)
modify_request.Replace("displayname", []string{data.DisplayName})
modify_request.Replace("givenname", []string{data.GivenName})
modify_request.Replace("sn", []string{data.Surname})
@@ -75,7 +75,7 @@ func handleProfile(w http.ResponseWriter, r *http.Request) {
modify_request.Replace(FIELD_NAME_PROFILE_PICTURE, []string{data.ProfilePicture})
}
- err = login.conn.Modify(modify_request)
+ err = user.Login.conn.Modify(modify_request)
if err != nil {
data.ErrorMessage = err.Error()
} else {
@@ -88,7 +88,7 @@ func handleProfile(w http.ResponseWriter, r *http.Request) {
}
type PasswdTplData struct {
- Status *LoginStatus
+ User *LoggedUser
ErrorMessage string
TooShortError bool
NoMatchError bool
@@ -98,13 +98,13 @@ type PasswdTplData struct {
func handlePasswd(w http.ResponseWriter, r *http.Request) {
templatePasswd := getTemplate("passwd.html")
- login := checkLogin(w, r)
- if login == nil {
+ user := RequireUserHtml(w, r)
+ if user == nil {
return
}
data := &PasswdTplData{
- Status: login,
+ User: user,
ErrorMessage: "",
Success: false,
}
@@ -120,11 +120,11 @@ func handlePasswd(w http.ResponseWriter, r *http.Request) {
} else if password2 != password {
data.NoMatchError = true
} else {
- modify_request := ldap.NewModifyRequest(login.Info.DN, nil)
+ modify_request := ldap.NewModifyRequest(user.Login.Info.DN(), nil)
pw, err := SSHAEncode(password)
if err == nil {
modify_request.Replace("userpassword", []string{pw})
- err := login.conn.Modify(modify_request)
+ err := user.Login.conn.Modify(modify_request)
if err != nil {
data.ErrorMessage = err.Error()
} else {
diff --git a/quotas.go b/quotas.go
new file mode 100644
index 0000000..894ea3c
--- /dev/null
+++ b/quotas.go
@@ -0,0 +1,151 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+
+ garage "git.deuxfleurs.fr/garage-sdk/garage-admin-sdk-golang"
+ "github.com/go-ldap/ldap/v3"
+)
+
+const (
+ // --- Default Quota Values ---
+ QUOTA_WEBSITE_SIZE_DEFAULT = 1024 * 1024 * 50 // 50MB
+ QUOTA_WEBSITE_SIZE_BURSTED = 1024 * 1024 * 200 // 200MB
+ QUOTA_WEBSITE_OBJECTS = 10000 // 10k objects
+ QUOTA_WEBSITE_COUNT = 5 // 5 buckets
+
+ // --- Per-user overridable fields ---
+ FIELD_QUOTA_WEBSITE_SIZE_BURSTED = "quota_website_size_bursted"
+ FIELD_QUOTA_WEBSITE_COUNT = "quota_website_count"
+)
+
+type UserQuota struct {
+ WebsiteCount int64
+ WebsiteSizeDefault int64
+ WebsiteSizeBursted int64
+ WebsiteObjects int64
+}
+
+func NewUserQuota() *UserQuota {
+ return &UserQuota{
+ WebsiteCount: QUOTA_WEBSITE_COUNT,
+ WebsiteSizeDefault: QUOTA_WEBSITE_SIZE_DEFAULT,
+ WebsiteSizeBursted: QUOTA_WEBSITE_SIZE_BURSTED,
+ WebsiteObjects: QUOTA_WEBSITE_OBJECTS,
+ }
+}
+
+var (
+ ErrQuotaEmpty = fmt.Errorf("No quota is defined for this entry")
+ ErrQuotaInvalid = fmt.Errorf("The defined quota can't be parsed")
+)
+
+func entryToQuota(entry *ldap.Entry, field string) (int64, error) {
+ f := entry.GetAttributeValue(field)
+ if f == "" {
+ return -1, ErrQuotaEmpty
+ }
+
+ q, err := strconv.ParseInt(f, 10, 64)
+ if err != nil {
+ return -1, errors.Join(ErrQuotaInvalid, err)
+ }
+ return q, nil
+}
+
+func NewUserQuotaFromEntry(entry *ldap.Entry) *UserQuota {
+ quotas := NewUserQuota()
+
+ if q, err := entryToQuota(entry, FIELD_QUOTA_WEBSITE_COUNT); err == nil {
+ quotas.WebsiteCount = q
+ }
+
+ if q, err := entryToQuota(entry, FIELD_QUOTA_WEBSITE_SIZE_BURSTED); err == nil {
+ quotas.WebsiteSizeBursted = q
+ }
+
+ return quotas
+}
+
+func (q *UserQuota) DefaultWebsiteQuota() *garage.UpdateBucketRequestQuotas {
+ qr := garage.NewUpdateBucketRequestQuotas()
+
+ qr.SetMaxSize(q.WebsiteSizeDefault)
+ qr.SetMaxObjects(q.WebsiteSizeBursted)
+
+ return qr
+}
+
+func (q *UserQuota) WebsiteSizeAdjust(sz int64) int64 {
+ if sz < q.WebsiteSizeDefault {
+ return q.WebsiteSizeDefault
+ } else if sz > q.WebsiteSizeBursted {
+ return q.WebsiteSizeBursted
+ } else {
+ return sz
+ }
+}
+
+func (q *UserQuota) WebsiteObjectAdjust(objs int64) int64 {
+ if objs > q.WebsiteObjects || objs <= 0 {
+ return q.WebsiteObjects
+ } else {
+ return objs
+ }
+}
+
+func (q *UserQuota) WebsiteSizeBurstedPretty() string {
+ return prettyValue(q.WebsiteSizeBursted)
+}
+
+// --- A quota stat we can use
+type QuotaStat struct {
+ Current int64 `json:"current"`
+ Max int64 `json:"max"`
+ Ratio float64 `json:"ratio"`
+ Burstable bool `json:"burstable"`
+}
+
+func NewQuotaStat(current, max int64, burstable bool) QuotaStat {
+ return QuotaStat{
+ Current: current,
+ Max: max,
+ Ratio: float64(current) / float64(max),
+ Burstable: burstable,
+ }
+}
+func (q *QuotaStat) IsFull() bool {
+ return q.Current >= q.Max
+}
+func (q *QuotaStat) Percent() int64 {
+ return int64(q.Ratio * 100)
+}
+
+func (q *QuotaStat) PrettyCurrent() string {
+ return prettyValue(q.Current)
+}
+func (q *QuotaStat) PrettyMax() string {
+ return prettyValue(q.Max)
+}
+
+func prettyValue(v int64) string {
+ if v < 1024 {
+ return fmt.Sprintf("%d octets", v)
+ }
+ v = v / 1024
+ if v < 1024 {
+ return fmt.Sprintf("%d kio", v)
+ }
+ v = v / 1024
+ if v < 1024 {
+ return fmt.Sprintf("%d Mio", v)
+ }
+ v = v / 1024
+ if v < 1024 {
+ return fmt.Sprintf("%d Gio", v)
+ }
+ v = v / 1024
+ return fmt.Sprintf("%d Tio", v)
+}
diff --git a/templates/garage_key.html b/templates/garage_key.html
index b839fcb..cf56822 100644
--- a/templates/garage_key.html
+++ b/templates/garage_key.html
@@ -3,7 +3,7 @@
{{define "body"}}
<div class="d-flex">
<h4>Mes identifiants</h4>
- <a class="ml-auto btn btn-link" href="/garage/website">Mes sites webs</a>
+ <a class="ml-auto btn btn-link" href="/website">Mes sites webs</a>
<a class="ml-4 btn btn-info" href="/">Menu principal</a>
</div>
@@ -21,12 +21,12 @@
<table class="table mt-4">
<tbody>
<tr>
- <th scope="row">Identifiant de clé</th>
- <td>{{ .Key.AccessKeyId }}</td>
+ <th scope="row" class="col-md-2">Identifiant de clé</th>
+ <td>{{ .S3KeyInfo.AccessKeyId }}</td>
</tr>
<tr>
<th scope="row">Clé secrète</th>
- <td>{{ .Key.SecretAccessKey }}</td>
+ <td><a href="#" onclick="document.getElementById('secret_key').style.display='inline'; this.style.display='none'">Cliquer pour afficher la clé secrète</a><span id="secret_key" style="display: none">{{ .S3KeyInfo.SecretAccessKey }}</span></td>
</tr>
<tr>
<th scope="row">Région</th>
@@ -58,12 +58,12 @@
</button>
</h2>
</div>
- <div id="awscli" class="collapse show" aria-labelledby="awscli-title" data-parent="#softconfig">
+ <div id="awscli" class="collapse" aria-labelledby="awscli-title" data-parent="#softconfig">
<div class="card-body">
<p>Créez un fichier nommé <code>~/.awsrc</code> :</p>
<pre>
-export AWS_ACCESS_KEY_ID={{ .Key.AccessKeyId }}
-export AWS_SECRET_ACCESS_KEY={{ .Key.SecretAccessKey }}
+export AWS_ACCESS_KEY_ID={{ .S3KeyInfo.AccessKeyId }}
+export AWS_SECRET_ACCESS_KEY={{ .S3KeyInfo.SecretAccessKey }}
export AWS_DEFAULT_REGION='garage'
function aws { command aws --endpoint-url https://garage.deuxfleurs.fr $@ ; }
@@ -97,8 +97,8 @@ aws s3 cp /tmp/a.txt s3://my-bucket
mc alias set \
garage \
https://garage.deuxfleurs.fr \
- {{ .Key.AccessKeyId }} \
- {{ .Key.SecretAccessKey }} \
+ {{ .S3KeyInfo.AccessKeyId }} \
+ {{ .S3KeyInfo.SecretAccessKey }} \
--api S3v4
</pre>
<p>Et ensuite pour utiliser Minio CLI avec :</p>
@@ -176,7 +176,7 @@ hugo deploy
<tbody>
<tr>
<th scope="row">Nom d'utilisateur-ice</th>
- <td>{{ .Status.Info.Username }}</td>
+ <td>{{ .Login.Info.Username }}</td>
</tr>
<tr>
<th scope="row">Mot de passe</th>
@@ -207,7 +207,7 @@ hugo deploy
<div class="card-body">
<p>Un exemple avec SCP :</p>
<pre>
-scp -oHostKeyAlgorithms=+ssh-rsa -P2222 -r ./public {{ .Status.Info.Username }}@bagage.deuxfleurs.fr:mon_bucket/
+scp -oHostKeyAlgorithms=+ssh-rsa -P2222 -r ./public {{ .Login.Info.Username }}@bagage.deuxfleurs.fr:mon_bucket/
</pre>
</div>
</div>
diff --git a/templates/garage_website_inspect.html b/templates/garage_website_inspect.html
index bc60711..37142df 100644
--- a/templates/garage_website_inspect.html
+++ b/templates/garage_website_inspect.html
@@ -2,57 +2,80 @@
{{define "body"}}
<div class="d-flex">
- <h4>Inspecter le site web</h4>
- <a class="ml-auto btn btn-link" href="/garage/key">Mes identifiants</a>
- <a class="ml-4 btn btn-success" href="/garage/website/new">Nouveau site web</a>
- <a class="ml-4 btn btn-info" href="/garage/website">Mes sites webs</a>
+ <!--<h4>Inspecter les sites webs</h4>-->
+ <a class="ml-auto btn btn-link" href="/website/configure">Mes identifiants</a>
+ <a class="ml-4 btn btn-info" href="/">Menu principal</a>
</div>
-<table class="table mt-4">
- <tbody>
- <tr>
- <th scope="row">ID</th>
- <td>{{ .Bucket.Id }}</td>
- </tr>
- <tr>
- <th scope="row">URLs</th>
- <td>
- {{ range $alias := .Bucket.GlobalAliases }}
- {{ if contains $alias "." }}
- https://{{ $alias }}
- {{ else }}
- https://{{ $alias }}.web.deuxfleurs.fr
- {{ end }}
- {{ end }}
- </td>
- </tr>
- <tr>
- <th scope="row">Document d'index</th>
- <td> {{ .IndexDoc }}</td>
- </tr>
- <tr>
- <th scope="row">Document d'erreur</th>
- <td>{{ .ErrorDoc }}</td>
- </tr>
- <tr>
- <th scope="row">Nombre de fichiers</th>
- <td>{{ .Bucket.Objects }} / {{ .MaxObjects }}</td>
- </tr>
- <tr>
- <th scope="row">Espace utilisé</th>
- <td>{{ .Bucket.Bytes }} / {{ .MaxSize }} octets</td>
- </tr>
- </tbody>
-</table>
-
-<h4>Configurer le nom de domaine</h4>
-
-{{ range $alias := .Bucket.GlobalAliases }}
-{{ if contains $alias "." }}
-<p> Le nom de domaine {{ $alias }} n'est pas géré par Deuxfleurs, il vous revient donc de configurer la zone DNS. Vous devez ajouter une entrée <code>CNAME garage.deuxfleurs.fr</code> ou <code>ALIAS garage.deuxfleurs.fr</code> auprès de votre hébergeur DNS, qui est souvent aussi le bureau d'enregistrement (eg. Gandi, GoDaddy, BookMyName, etc.).</p>
-{{ else }}
-<p> Le nom de domaine https://{{ $alias }}.web.deuxfleurs.fr est fourni par Deuxfleurs, il n'y a pas de configuration à faire.</p>
-{{ end }}
+<div class="row">
+ {{ if .Err }}
+ <div class="col-md-12 mt-3">
+ <div class="alert alert-danger">{{ .Err.Error }}</div>
+ </div>
+ {{ end }}
+
+ <div class="col-md-3 mt-3">
+ <a class="btn btn-primary btn-block" href="/website/new">
+ <svg id="i-plus" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="18" height="18" fill="none" stroke="currentcolor" stroke-linecap="round" stroke-linejoin="round" stroke-width="6">
+ <path d="M16 2 L16 30 M2 16 L30 16" />
+ </svg>
+ <span class="ml-1">Nouveau site web</span>
+ </a>
+
+ <div class="list-group mt-3">
+ {{ $view := .View }}
+ {{ range $wid := .Describe.Websites }}
+ {{ if eq $wid.Internal $view.Name.Internal }}
+ <a href="/website/inspect/{{ $wid.Pretty }}" class="list-group-item list-group-item-action active">
+ {{ $wid.Url }}
+ </a>
+ {{ else }}
+ <a href="/website/inspect/{{ $wid.Pretty }}" class="list-group-item list-group-item-action">
+ {{ $wid.Url }}
+ </a>
+ {{ end }}
+ {{ end }}
+ </div>
+
+ <p class="text-center mt-2">
+ {{ .Describe.AllowedWebsites.Current }} sites créés sur {{ .Describe.AllowedWebsites.Max }}<br/>
+ Jusqu'à {{ .Describe.BurstBucketQuotaSize }} par site web
+ </p>
+ </div>
+ <div class="col-md-9">
+ <h2>{{ .View.Name.Url }}</h2>
+
+ <h5 class="mt-3">Quotas</h5>
+ <div class="progress mt-3">
+ <div class="progress-bar" role="progressbar" aria-valuenow="{{ .View.Size.Current }}" aria-valuemin="0" aria-valuemax="{{ .View.Size.Max }}" style="width: {{ .View.Size.Percent }}%; min-width: 2em;">
+ {{ .View.Size.Ratio }}%
+ </div>
+ </div>
+
+ <p class="text-center">
+ {{ .View.Size.PrettyCurrent }} utilisé sur un maximum de {{ .View.Size.PrettyMax }}
+ {{ if gt .View.Files.Ratio 0.5 }}
+ <br>{{ .View.Files.Current }} fichiers sur un maximum de {{ .View.Files.Max }}
+ {{ end }}
+ </p>
+
+ <h5 class="mt-3">Actions</h5>
+ <form action="" method="post">
+ <div class="btn-group" role="group" aria-label="Actions sur le site web">
+ <button class="btn btn-secondary" name="action" value="increase_quota">Augmenter le quota</button>
+ <a class="btn btn-secondary disabled">Changer le nom de domaine</a>
+ <button class="btn btn-danger" name="action" value="delete_bucket">Supprimer</button>
+ </div>
+ </form>
+
+
+ {{ if .View.Name.Expanded }}
+ <h5 class="mt-5">Vous ne savez pas comment configurer votre nom de domaine ?</h5>
+ <p> Le nom de domaine {{ .View.Name.Url }} n'est pas géré par Deuxfleurs, il vous revient donc de configurer la zone DNS. Vous devez ajouter une entrée <code>CNAME garage.deuxfleurs.fr</code> ou <code>ALIAS garage.deuxfleurs.fr</code> auprès de votre hébergeur DNS, qui est souvent aussi le bureau d'enregistrement (eg. Gandi, GoDaddy, BookMyName, etc.).</p>
+ {{ end }}
+
+
+ </div>
+</div>
{{ end }}
-{{end}}
diff --git a/templates/garage_website_list.html b/templates/garage_website_list.html
deleted file mode 100644
index ded8096..0000000
--- a/templates/garage_website_list.html
+++ /dev/null
@@ -1,38 +0,0 @@
-{{define "title"}}Sites webs |{{end}}
-
-{{define "body"}}
-
-<div class="d-flex">
- <h4>Sites webs</h4>
- <a class="ml-auto btn btn-link" href="/garage/key">Mes identifiants</a>
- <a class="ml-4 btn btn-success" href="/garage/website/new">Nouveau site web</a>
- <a class="ml-4 btn btn-info" href="/">Menu principal</a>
-</div>
-
-<table class="table mt-4">
- <thead>
- <th scope="col">ID</th>
- <th scope="col">URLs</th>
- </thead>
- <tbody>
- {{ range $buck := .Key.Buckets }}
- {{ if $buck.GlobalAliases }}
- <tr>
- <td>
- <a href="/garage/website/b/{{$buck.Id}}">{{$buck.Id}}</a>
- </td>
- <td>
- {{ range $alias := $buck.GlobalAliases }}
- {{ if contains $alias "." }}
- https://{{ $alias }}
- {{ else }}
- https://{{ $alias }}.web.deuxfleurs.fr
- {{ end }}
- {{ end }}
- </td>
- </tr>
- {{ end }}
- {{ end }}
- </tbody>
-</table>
-{{end}}
diff --git a/templates/garage_website_new.html b/templates/garage_website_new.html
index f1cd847..7ee4936 100644
--- a/templates/garage_website_new.html
+++ b/templates/garage_website_new.html
@@ -3,8 +3,16 @@
{{define "body"}}
<div class="d-flex">
<h4>Créer un site web</h4>
- <a class="ml-auto btn btn-link" href="/garage/key">Mes identifiants</a>
- <a class="ml-4 btn btn-info" href="/garage/website">Mes sites webs</a>
+ <a class="ml-auto btn btn-link" href="/website/configure">Mes identifiants</a>
+ <a class="ml-4 btn btn-info" href="/website">Mes sites webs</a>
+</div>
+
+<div class="row mt-3">
+ <div class="col-md-12">
+ {{if .Err}}
+ <div class="alert alert-danger">{{ .Err.Error }}</div>
+ {{end}}
+ </div>
</div>
<ul class="nav nav-tabs" id="proto" role="tablist">
@@ -17,6 +25,7 @@
</ul>
<div class="tab-content" id="protocols">
+
<div class="tab-pane fade show active" id="dnsint" role="tabpanel" aria-labelledby="dnsint-tab">
<form method="POST" class="mt-4">
<div class="form-row">
diff --git a/templates/home.html b/templates/home.html
index 241a59d..dd88d13 100644
--- a/templates/home.html
+++ b/templates/home.html
@@ -2,7 +2,7 @@
{{define "body"}}
<div class="alert alert-info">
- Bienvenue, <strong>{{ .Login.WelcomeName }}</strong> !
+ Bienvenue, <strong>{{ .User.WelcomeName }}</strong> !
</div>
<div class="d-flex">
<a class="ml-auto btn btn-sm btn-dark" href="/logout">Se déconnecter</a>
@@ -24,16 +24,16 @@
<div class="mt-3">
<div class="card">
<div class="card-header">
- Garage
+ Mon espace sur la toile
</div>
<div class="list-group list-group-flush">
- <a class="list-group-item list-group-item-action" href="/garage/key">Mes identifiants</a>
- <a class="list-group-item list-group-item-action" href="/garage/website">Mes sites webs</a>
+ <a class="list-group-item list-group-item-action" href="/website/configure">Mes identifiants</a>
+ <a class="list-group-item list-group-item-action" href="/website">Mes sites Web</a>
</div>
</div>
</div>
-{{if .Login.CanInvite}}
+{{if .User.Capabilities.CanInvite}}
<div class="card mt-3">
<div class="card-header">
Inviter des gens sur Deuxfleurs
@@ -45,7 +45,7 @@
</div>
{{end}}
-{{if .Login.CanAdmin}}
+{{if .User.Capabilities.CanAdmin}}
<div class="card mt-3">
<div class="card-header">
Administration
diff --git a/templates/profile.html b/templates/profile.html
index 56461eb..17965a6 100644
--- a/templates/profile.html
+++ b/templates/profile.html
@@ -20,7 +20,7 @@
<div class="form-row">
<div class="form-group col-md-6">
<label>Identifiant:</label>
- <input type="text" disabled="true" class="form-control" value="{{ .Status.Info.Username }}" />
+ <input type="text" disabled="true" class="form-control" value="{{ .User.Login.Info.Username }}" />
</div>
<div class="form-group col-md-6">
<label for="mail">Adresse e-mail:</label>
diff --git a/website.go b/website.go
new file mode 100644
index 0000000..ba432c5
--- /dev/null
+++ b/website.go
@@ -0,0 +1,236 @@
+package main
+
+import (
+ "fmt"
+ garage "git.deuxfleurs.fr/garage-sdk/garage-admin-sdk-golang"
+ "sort"
+ "strings"
+)
+
+var (
+ ErrWebsiteNotFound = fmt.Errorf("Website not found")
+ ErrFetchBucketInfo = fmt.Errorf("Failed to fetch bucket information")
+ ErrWebsiteQuotaReached = fmt.Errorf("Can't create additional websites, quota reached")
+ ErrEmptyBucketName = fmt.Errorf("You can't create a website with an empty name")
+ ErrCantCreateBucket = fmt.Errorf("Can't create this bucket. Maybe another bucket already exists with this name or you have an invalid character")
+ ErrCantAllowKey = fmt.Errorf("Can't allow given key on the target bucket")
+ ErrCantConfigureBucket = fmt.Errorf("Unable to configure the bucket (activating website, adding quotas, etc.)")
+ ErrBucketDeleteNotEmpty = fmt.Errorf("You must remove all the files before deleting a bucket")
+ ErrBucketDeleteUnfinishedUpload = fmt.Errorf("You must remove all the unfinished multipart uploads before deleting a bucket")
+)
+
+type WebsiteId struct {
+ Pretty string `json:"name"`
+ Internal string `json:"-"`
+ Alt []string `json:"alt_name"`
+ Expanded bool `json:"expanded"`
+ Url string `json:"domain"`
+}
+
+func NewWebsiteId(id string, aliases []string) *WebsiteId {
+ pretty := id
+ var alt []string
+ if len(aliases) > 0 {
+ pretty = aliases[0]
+ alt = aliases[1:]
+ }
+ expanded := strings.Contains(pretty, ".")
+
+ url := pretty
+ if !expanded {
+ url = fmt.Sprintf("%s.web.deuxfleurs.fr", pretty)
+ }
+
+ return &WebsiteId{pretty, id, alt, expanded, url}
+}
+func NewWebsiteIdFromBucketInfo(binfo *garage.BucketInfo) *WebsiteId {
+ return NewWebsiteId(*binfo.Id, binfo.GlobalAliases)
+}
+
+type WebsiteController struct {
+ User *LoggedUser
+ WebsiteIdx map[string]*WebsiteId
+ PrettyList []string
+ WebsiteCount QuotaStat
+}
+
+func NewWebsiteController(user *LoggedUser) (*WebsiteController, error) {
+ idx := map[string]*WebsiteId{}
+ var wlist []string
+
+ keyInfo, err := user.S3KeyInfo()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, bckt := range keyInfo.Buckets {
+ if len(bckt.GlobalAliases) > 0 {
+ wid := NewWebsiteId(*bckt.Id, bckt.GlobalAliases)
+ idx[wid.Pretty] = wid
+ wlist = append(wlist, wid.Pretty)
+ }
+ }
+ sort.Strings(wlist)
+
+ maxW := user.Quota.WebsiteCount
+ quota := NewQuotaStat(int64(len(wlist)), maxW, true)
+
+ return &WebsiteController{user, idx, wlist, quota}, nil
+}
+
+type WebsiteDescribe struct {
+ AccessKeyId string `json:"access_key_id"`
+ SecretAccessKey string `json:"secret_access_key"`
+ AllowedWebsites *QuotaStat `json:"quota_website_count"`
+ BurstBucketQuotaSize string `json:"burst_bucket_quota_size"`
+ Websites []*WebsiteId `json:"vhosts"`
+}
+
+func (w *WebsiteController) Describe() (*WebsiteDescribe, error) {
+ s3key, err := w.User.S3KeyInfo()
+ if err != nil {
+ return nil, err
+ }
+
+ r := make([]*WebsiteId, 0, len(w.PrettyList))
+ for _, k := range w.PrettyList {
+ r = append(r, w.WebsiteIdx[k])
+ }
+ return &WebsiteDescribe{
+ *s3key.AccessKeyId,
+ *s3key.SecretAccessKey,
+ &w.WebsiteCount,
+ w.User.Quota.WebsiteSizeBurstedPretty(),
+ r}, nil
+}
+
+func (w *WebsiteController) Inspect(pretty string) (*WebsiteView, error) {
+ website, ok := w.WebsiteIdx[pretty]
+ if !ok {
+ return nil, ErrWebsiteNotFound
+ }
+
+ binfo, err := grgGetBucket(website.Internal)
+ if err != nil {
+ return nil, ErrFetchBucketInfo
+ }
+
+ return NewWebsiteView(binfo), nil
+}
+
+func (w *WebsiteController) Patch(pretty string, patch *WebsitePatch) (*WebsiteView, error) {
+ website, ok := w.WebsiteIdx[pretty]
+ if !ok {
+ return nil, ErrWebsiteNotFound
+ }
+
+ binfo, err := grgGetBucket(website.Internal)
+ if err != nil {
+ return nil, ErrFetchBucketInfo
+ }
+
+ // Patch the max size
+ urQuota := garage.NewUpdateBucketRequestQuotas()
+ urQuota.SetMaxSize(w.User.Quota.WebsiteSizeAdjust(binfo.Quotas.GetMaxSize()))
+ urQuota.SetMaxObjects(w.User.Quota.WebsiteObjectAdjust(binfo.Quotas.GetMaxObjects()))
+ if patch.Size != nil {
+ urQuota.SetMaxSize(w.User.Quota.WebsiteSizeAdjust(*patch.Size))
+ }
+
+ // Build the update
+ ur := garage.NewUpdateBucketRequest()
+ ur.SetQuotas(*urQuota)
+
+ // Call garage
+ binfo, err = grgUpdateBucket(website.Internal, ur)
+ if err != nil {
+ return nil, ErrCantConfigureBucket
+ }
+
+ return NewWebsiteView(binfo), nil
+}
+
+func (w *WebsiteController) Create(pretty string) (*WebsiteView, error) {
+ if pretty == "" {
+ return nil, ErrEmptyBucketName
+ }
+
+ if w.WebsiteCount.IsFull() {
+ return nil, ErrWebsiteQuotaReached
+ }
+
+ binfo, err := grgCreateBucket(pretty)
+ if err != nil {
+ return nil, ErrCantCreateBucket
+ }
+
+ s3key, err := w.User.S3KeyInfo()
+ if err != nil {
+ return nil, err
+ }
+
+ binfo, err = grgAllowKeyOnBucket(*binfo.Id, *s3key.AccessKeyId)
+ if err != nil {
+ return nil, ErrCantAllowKey
+ }
+
+ qr := w.User.Quota.DefaultWebsiteQuota()
+ wr := allowWebsiteDefault()
+
+ ur := garage.NewUpdateBucketRequest()
+ ur.SetWebsiteAccess(*wr)
+ ur.SetQuotas(*qr)
+
+ binfo, err = grgUpdateBucket(*binfo.Id, ur)
+ if err != nil {
+ return nil, ErrCantConfigureBucket
+ }
+
+ return NewWebsiteView(binfo), nil
+}
+
+func (w *WebsiteController) Delete(pretty string) error {
+ if pretty == "" {
+ return ErrEmptyBucketName
+ }
+
+ website, ok := w.WebsiteIdx[pretty]
+ if !ok {
+ return ErrWebsiteNotFound
+ }
+
+ binfo, err := grgGetBucket(website.Internal)
+ if err != nil {
+ return ErrFetchBucketInfo
+ }
+
+ if *binfo.Objects > int64(0) {
+ return ErrBucketDeleteNotEmpty
+ }
+
+ if *binfo.UnfinishedUploads > int32(0) {
+ return ErrBucketDeleteUnfinishedUpload
+ }
+
+ err = grgDeleteBucket(website.Internal)
+ return err
+}
+
+type WebsiteView struct {
+ Name *WebsiteId `json:"identified_as"`
+ Size QuotaStat `json:"quota_size"`
+ Files QuotaStat `json:"quota_files"`
+}
+
+func NewWebsiteView(binfo *garage.BucketInfo) *WebsiteView {
+ q := binfo.GetQuotas()
+
+ wid := NewWebsiteIdFromBucketInfo(binfo)
+ size := NewQuotaStat(*binfo.Bytes, (&q).GetMaxSize(), true)
+ objects := NewQuotaStat(*binfo.Objects, (&q).GetMaxObjects(), false)
+ return &WebsiteView{wid, size, objects}
+}
+
+type WebsitePatch struct {
+ Size *int64 `json:"quota_size"`
+}