diff options
author | Quentin <quentin@dufour.io> | 2023-09-26 06:44:36 +0000 |
---|---|---|
committer | Quentin <quentin@dufour.io> | 2023-09-26 06:44:36 +0000 |
commit | 49d8e81fbea0d4703a33e87a807927169a8060ac (patch) | |
tree | d0b655454d5e13ed2238060fee27fc0d951d64c8 | |
parent | 1e75c21b65021da0c3c5a8be9be12114a2327464 (diff) | |
parent | 706ff58a6f6608719feda15075d50f978df39c5b (diff) | |
download | guichet-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.go | 98 | ||||
-rw-r--r-- | api.go | 127 | ||||
-rw-r--r-- | cli.go | 44 | ||||
-rw-r--r-- | directory.go | 10 | ||||
-rw-r--r-- | garage.go | 242 | ||||
-rw-r--r-- | go.mod | 3 | ||||
-rw-r--r-- | go.sum | 5 | ||||
-rw-r--r-- | integration/config/bottin.json | 10 | ||||
-rw-r--r-- | integration/docker-compose.yml | 8 | ||||
-rw-r--r-- | invite.go | 37 | ||||
-rw-r--r-- | login.go | 294 | ||||
-rw-r--r-- | main.go | 224 | ||||
-rw-r--r-- | picture.go | 10 | ||||
-rw-r--r-- | profile.go | 40 | ||||
-rw-r--r-- | quotas.go | 151 | ||||
-rw-r--r-- | templates/garage_key.html | 22 | ||||
-rw-r--r-- | templates/garage_website_inspect.html | 123 | ||||
-rw-r--r-- | templates/garage_website_list.html | 38 | ||||
-rw-r--r-- | templates/garage_website_new.html | 13 | ||||
-rw-r--r-- | templates/home.html | 12 | ||||
-rw-r--r-- | templates/profile.html | 2 | ||||
-rw-r--r-- | website.go | 236 |
22 files changed, 1261 insertions, 488 deletions
@@ -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 { @@ -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 +} @@ -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 @@ -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) } @@ -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 @@ -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: @@ -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 +} @@ -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 } } @@ -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 } @@ -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"` +} |