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 grgAddGlobalAlias(bid, alias string) (*garage.BucketInfo, error) { + client, ctx := gadmin() + + resp, _, err := client.BucketApi.PutBucketGlobalAlias(ctx).Id(bid).Alias(alias).Execute() + if err != nil { + 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 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 +} + +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 + } + return resp, nil +} + func grgGetBucket(bid string) (*garage.BucketInfo, error) { client, ctx := gadmin() resp, _, err := client.BucketApi.GetBucketInfo(ctx, bid).Execute() if err != nil { - fmt.Printf("%+v\n", err) + 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 grgDeleteBucket(bid string) error { + client, ctx := gadmin() - 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 - } - // 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 -} - -type keyView struct { - Status *LoginStatus - Key *garage.KeyInfo -} - -func handleGarageKey(w http.ResponseWriter, r *http.Request) { - login, s3key, err := checkLoginAndS3(w, r) + _, 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 handleGarageWebsiteList(w http.ResponseWriter, r *http.Request) { - login, s3key, err := checkLoginAndS3(w, r) - if err != nil { - log.Println(err) +func handleWebsiteList(w http.ResponseWriter, r *http.Request) { + user := RequireUserHtml(w, r) + if user == nil { return } - view := webListView{Status: login, Key: s3key} - tWebsiteList := getTemplate("garage_website_list.html") - tWebsiteList.Execute(w, &view) -} - -func handleGarageWebsiteNew(w http.ResponseWriter, r *http.Request) { - _, s3key, err := checkLoginAndS3(w, r) + ctrl, err := NewWebsiteController(user) if err != nil { - log.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) return } + 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 handleWebsiteNew(w http.ResponseWriter, r *http.Request) { + user := RequireUserHtml(w, r) + if user == nil { + return + } + + ctrl, err := NewWebsiteController(user) + if err != nil { + 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"] + + 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 := webInspectView{ - Status: login, - Key: s3key, - Bucket: binfo, - IndexDoc: (&wc).GetIndexDocument(), - ErrorDoc: (&wc).GetErrorDocument(), - MaxObjects: (&q).GetMaxObjects(), - MaxSize: (&q).GetMaxSize(), } + 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, err := NewLdapCon() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - l := ldapOpen(w) - if l == nil { - return nil - } - - 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"}}

Mes identifiants

- Mes sites webs + Mes sites webs Menu principal
@@ -21,12 +21,12 @@ - - + + - + @@ -58,12 +58,12 @@ -
+

Créez un fichier nommé ~/.awsrc :

-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
                         

Et ensuite pour utiliser Minio CLI avec :

@@ -176,7 +176,7 @@ hugo deploy
- + @@ -207,7 +207,7 @@ hugo deploy

Un exemple avec SCP :

-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/
                         
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"}}
-

Inspecter le site web

- Mes identifiants - Nouveau site web - Mes sites webs + + Mes identifiants + Menu principal
-
Identifiant de clé{{ .Key.AccessKeyId }}Identifiant de clé{{ .S3KeyInfo.AccessKeyId }}
Clé secrète{{ .Key.SecretAccessKey }}Cliquer pour afficher la clé secrète
Région
Nom d'utilisateur-ice{{ .Status.Info.Username }}{{ .Login.Info.Username }}
Mot de passe
- - - - - - - - - - - - - - - - - - - - - - - - - - -
ID{{ .Bucket.Id }}
URLs - {{ range $alias := .Bucket.GlobalAliases }} - {{ if contains $alias "." }} - https://{{ $alias }} - {{ else }} - https://{{ $alias }}.web.deuxfleurs.fr - {{ end }} - {{ end }} -
Document d'index {{ .IndexDoc }}
Document d'erreur{{ .ErrorDoc }}
Nombre de fichiers{{ .Bucket.Objects }} / {{ .MaxObjects }}
Espace utilisé{{ .Bucket.Bytes }} / {{ .MaxSize }} octets
+
+ {{ if .Err }} +
+
{{ .Err.Error }}
+
+ {{ end }} -

Configurer le nom de domaine

+
+ + + + + Nouveau site web + -{{ range $alias := .Bucket.GlobalAliases }} -{{ if contains $alias "." }} -

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 CNAME garage.deuxfleurs.fr ou ALIAS garage.deuxfleurs.fr auprès de votre hébergeur DNS, qui est souvent aussi le bureau d'enregistrement (eg. Gandi, GoDaddy, BookMyName, etc.).

-{{ else }} -

Le nom de domaine https://{{ $alias }}.web.deuxfleurs.fr est fourni par Deuxfleurs, il n'y a pas de configuration à faire.

-{{ end }} +
+ {{ $view := .View }} + {{ range $wid := .Describe.Websites }} + {{ if eq $wid.Internal $view.Name.Internal }} + + {{ $wid.Url }} + + {{ else }} + + {{ $wid.Url }} + + {{ end }} + {{ end }} +
+ +

+ {{ .Describe.AllowedWebsites.Current }} sites créés sur {{ .Describe.AllowedWebsites.Max }}
+ Jusqu'à {{ .Describe.BurstBucketQuotaSize }} par site web +

+
+
+

{{ .View.Name.Url }}

+ +
Quotas
+
+
+ {{ .View.Size.Ratio }}% +
+
+ +

+ {{ .View.Size.PrettyCurrent }} utilisé sur un maximum de {{ .View.Size.PrettyMax }} + {{ if gt .View.Files.Ratio 0.5 }} +
{{ .View.Files.Current }} fichiers sur un maximum de {{ .View.Files.Max }} + {{ end }} +

+ +
Actions
+
+
+ + Changer le nom de domaine + +
+
+ + + {{ if .View.Name.Expanded }} +
Vous ne savez pas comment configurer votre nom de domaine ?
+

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 CNAME garage.deuxfleurs.fr ou ALIAS garage.deuxfleurs.fr auprès de votre hébergeur DNS, qui est souvent aussi le bureau d'enregistrement (eg. Gandi, GoDaddy, BookMyName, etc.).

+ {{ end }} + + +
+
{{ 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"}} - -
-

Sites webs

- Mes identifiants - Nouveau site web - Menu principal -
- - - - - - - - {{ range $buck := .Key.Buckets }} - {{ if $buck.GlobalAliases }} - - - - - {{ end }} - {{ end }} - -
IDURLs
- {{$buck.Id}} - - {{ range $alias := $buck.GlobalAliases }} - {{ if contains $alias "." }} - https://{{ $alias }} - {{ else }} - https://{{ $alias }}.web.deuxfleurs.fr - {{ end }} - {{ end }} -
-{{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"}}

Créer un site web

- Mes identifiants - Mes sites webs + Mes identifiants + Mes sites webs +
+ +
+
+ {{if .Err}} +
{{ .Err.Error }}
+ {{end}} +
+
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"}}
- Bienvenue, {{ .Login.WelcomeName }} ! + Bienvenue, {{ .User.WelcomeName }} !
Se déconnecter @@ -24,16 +24,16 @@
- Garage + Mon espace sur la toile
-{{if .Login.CanInvite}} +{{if .User.Capabilities.CanInvite}}
Inviter des gens sur Deuxfleurs @@ -45,7 +45,7 @@
{{end}} -{{if .Login.CanAdmin}} +{{if .User.Capabilities.CanAdmin}}
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 @@
- +
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"` +}