diff --git a/garage.go b/garage.go index 7cd879b..0176382 100644 --- a/garage.go +++ b/garage.go @@ -61,6 +61,33 @@ func grgCreateBucket(bucket string) (*garage.BucketInfo, error) { return binfo, nil } +func grgCreateLocalBucket(bucket, gkey string) (*garage.BucketInfo, error) { + client, ctx := gadmin() + + is_true := true + is_false := false + + la := garage.CreateBucketRequestLocalAlias { + AccessKeyId: &gkey, + Alias: &bucket, + Allow: &garage.CreateBucketRequestLocalAliasAllow { + Read: &is_true, + Write: &is_true, + Owner: &is_false, + }, + } + + br := garage.NewCreateBucketRequest() + br.SetLocalAlias(la) + + binfo, _, err := client.BucketApi.CreateBucket(ctx).CreateBucketRequest(*br).Execute() + if err != nil { + fmt.Printf("%+v\n", err) + return nil, err + } + return binfo, nil +} + func grgAllowKeyOnBucket(bid, gkey string) (*garage.BucketInfo, error) { client, ctx := gadmin() diff --git a/login.go b/login.go index 277e3ae..62a72c1 100644 --- a/login.go +++ b/login.go @@ -109,14 +109,21 @@ func NewLdapCon() (*ldap.Conn, error) { // --- Capabilities --- type Capabilities struct { - CanAdmin bool - CanInvite bool + CanAdmin bool + CanInvite bool + CanUseEmail bool } func NewCapabilities(login *LoginStatus, entry *ldap.Entry) *Capabilities { // Initialize canAdmin := false canInvite := false + canUseEmail := false + + // Composable logic + hasAeroBucketId := false + hasAeroBucketName := false + hasAeroCryptoRoot := false // Special case for the "admin" account that is de-facto admin canAdmin = strings.EqualFold(login.Info.DN(), config.AdminAccount) @@ -132,12 +139,22 @@ func NewCapabilities(login *LoginStatus, entry *ldap.Entry) *Capabilities { canAdmin = true } } + } else if strings.EqualFold(attr.Name, FIELD_AEROGRAMME_CRYPTOROOT) { + hasAeroCryptoRoot = true + } else if strings.EqualFold(attr.Name, FIELD_AEROGRAMME_BUCKET_ID) { + hasAeroBucketId = true + } else if strings.EqualFold(attr.Name, FIELD_AEROGRAMME_BUCKET_NAME) { + hasAeroBucketName = true } } + // Boolean logic + canUseEmail = hasAeroBucketId && hasAeroBucketName && hasAeroCryptoRoot + return &Capabilities{ CanAdmin: canAdmin, CanInvite: canInvite, + CanUseEmail: canUseEmail, } } @@ -173,6 +190,10 @@ func NewLoggedUser(login *LoginStatus) (*LoggedUser, error) { FIELD_NAME_PROFILE_PICTURE, FIELD_QUOTA_WEBSITE_SIZE_BURSTED, FIELD_QUOTA_WEBSITE_COUNT, + FIELD_QUOTA_PIM_SIZE_BURSTED, + FIELD_AEROGRAMME_CRYPTOROOT, + FIELD_AEROGRAMME_BUCKET_ID, + FIELD_AEROGRAMME_BUCKET_NAME, }, nil) diff --git a/main.go b/main.go index 39c7f08..30fd42e 100644 --- a/main.go +++ b/main.go @@ -163,6 +163,9 @@ func server(args []string) { r.HandleFunc("/website/inspect/{bucket}", handleWebsiteInspect) r.HandleFunc("/website/vhost/{bucket}", handleWebsiteVhost) + r.HandleFunc("/pim/setup", handlePimSetup) + r.HandleFunc("/pim/inspect", handlePimInspect) + r.HandleFunc("/invite/new_account", handleInviteNewAccount) r.HandleFunc("/invite/send_code", handleInviteSendCode) r.HandleFunc("/invitation/{code}", handleInvitationCode) diff --git a/pim_ctrl.go b/pim_ctrl.go new file mode 100644 index 0000000..8589536 --- /dev/null +++ b/pim_ctrl.go @@ -0,0 +1,211 @@ +package main + +import ( + "errors" + "fmt" + "os/exec" + "strings" + "slices" + garage "git.deuxfleurs.fr/garage-sdk/garage-admin-sdk-golang" + "github.com/go-ldap/ldap/v3" +) + +const ( + FIELD_AEROGRAMME_CRYPTOROOT = "aero_cryptoroot" + FIELD_AEROGRAMME_BUCKET_ID = "aero_bucket_id" + FIELD_AEROGRAMME_BUCKET_NAME = "aero_bucket" + LOCAL_ALIAS_NAME = "aerogramme" +) + + +var ( + ErrPimBuilderDirty = fmt.Errorf("builder is dirty.") + ErrPimBucketLocalAliasNotFound = fmt.Errorf("local alias does not exist in garage or points to the wrong bucket.") + ErrPimBucketIdEmpty = fmt.Errorf("missing bucket ID in LDAP.") + ErrPimBucketNameEmpty = fmt.Errorf("missing bucket local garage alias in LDAP.") + ErrPimBucketInfoNotFetched = fmt.Errorf("bucket info has not been fetched.") + ErrPimCryptoRootEmpty = fmt.Errorf("missing cryptoroot in LDAP.") + ErrPimCantCreateBucket = fmt.Errorf("unable to create PIM bucket.") +) + +type PimBuilder struct { + user *LoggedUser + cryptoroot string + bucketId string + bucketName string + bucketInfo *garage.BucketInfo + dirty bool + errors []error +} + +func NewPimBuilder(user *LoggedUser) *PimBuilder { + return &PimBuilder { + user: user, + cryptoroot: user.Entry.GetAttributeValue(FIELD_AEROGRAMME_CRYPTOROOT), + bucketId: user.Entry.GetAttributeValue(FIELD_AEROGRAMME_BUCKET_ID), + bucketName: user.Entry.GetAttributeValue(FIELD_AEROGRAMME_BUCKET_NAME), + bucketInfo: nil, + dirty: false, + errors: make([]error, 0), + } +} +func (pm *PimBuilder) CheckCryptoRoot() *PimBuilder { + if pm.cryptoroot == "" { + cmd := exec.Command("./aerogramme", "tools", "crypto-root", "new-clear-text") + var out strings.Builder + cmd.Stdout = &out + err := cmd.Run() + if err != nil { + pm.errors = append(pm.errors, err) + return pm + } + pm.cryptoroot = out.String() + pm.dirty = true + } + return pm +} + +func (pm *PimBuilder) CheckBucket() *PimBuilder { + keyInfo, err := pm.user.S3KeyInfo() + if err != nil { + pm.errors = append(pm.errors, err) + return pm + } + + if pm.bucketId == "" { + candidateName := LOCAL_ALIAS_NAME + var bInfo *garage.BucketInfo + var err error + + err = nil + for _, ext := range []string{"", "-1", "-2", "-3", "-4", "-5"} { + candidateName = LOCAL_ALIAS_NAME + ext + bInfo, err = grgCreateLocalBucket(candidateName, *keyInfo.AccessKeyId) + if err == nil { + break + } + } + + if err != nil { + pm.errors = append(pm.errors, ErrPimCantCreateBucket) + return pm + } + + qr := pm.user.Quota.DefaultPimQuota() + ur := garage.NewUpdateBucketRequest() + ur.SetQuotas(*qr) + bInfo, err = grgUpdateBucket(*bInfo.Id, ur) + if err != nil { + pm.errors = append(pm.errors, err) + return pm + } + + pm.bucketId = *bInfo.Id + pm.bucketName = candidateName + pm.bucketInfo = bInfo + pm.dirty = true + } else { + binfo, err := grgGetBucket(pm.bucketId) + if err != nil { + pm.errors = append(pm.errors, err) + return pm + } + pm.bucketInfo = binfo + + //@TODO find my key, check that pm.bucketName exists in bucketLocalAliases + nameFound := false + for _, k := range binfo.Keys { + if *k.AccessKeyId != *keyInfo.AccessKeyId { + // not my key + continue + } + if slices.Contains(k.BucketLocalAliases, pm.bucketName) { + nameFound = true + break + } + } + if !nameFound { + pm.errors = append(pm.errors, ErrPimBucketLocalAliasNotFound) + return pm + } + } + + return pm +} + +func (pm *PimBuilder) LdapUpdate() *PimBuilder { + if len(pm.errors) > 0 { + return pm + } + + modify_request := ldap.NewModifyRequest(pm.user.Login.Info.DN(), nil) + modify_request.Replace(FIELD_AEROGRAMME_CRYPTOROOT, []string{pm.cryptoroot}) + modify_request.Replace(FIELD_AEROGRAMME_BUCKET_NAME, []string{pm.bucketName}) + modify_request.Replace(FIELD_AEROGRAMME_BUCKET_ID, []string{pm.bucketId}) + err := pm.user.Login.conn.Modify(modify_request) + if err != nil { + pm.errors = append(pm.errors, err) + return pm + } + + pm.dirty = false + return pm +} + +func (pm *PimBuilder) Build() (*PimController, error) { + // checks + if pm.dirty { + pm.errors = append(pm.errors, ErrPimBuilderDirty) + } + if pm.bucketId == "" { + pm.errors = append(pm.errors, ErrPimBucketIdEmpty) + } + if pm.bucketName == "" { + pm.errors = append(pm.errors, ErrPimBucketNameEmpty) + } + if pm.bucketInfo == nil { + pm.errors = append(pm.errors, ErrPimBucketInfoNotFetched) + } + if pm.cryptoroot == "" { + pm.errors = append(pm.errors, ErrPimCryptoRootEmpty) + } + if len(pm.errors) > 0 { + err := errors.New("PIM Builder failed") + for _, iterErr := range pm.errors { + err = errors.Join(err, iterErr) + } + return nil, err + } + + // quotas + q := pm.bucketInfo.GetQuotas() + size := NewQuotaStat(*pm.bucketInfo.Bytes, (&q).GetMaxSize(), true) + objects := NewQuotaStat(*pm.bucketInfo.Objects, (&q).GetMaxObjects(), false) + + // final object + pim_ctl := &PimController { + BucketId: pm.bucketId, + BucketName: pm.bucketName, + Size: size, + Files: objects, + user: pm.user, + bucketInfo: pm.bucketInfo, + cryptoroot: pm.cryptoroot, + + } + + return pim_ctl, nil +} + +// --- Controller --- +type PimController struct { + BucketId string `json:"bucket_id"` + BucketName string `json:"bucket_name"` + Size QuotaStat `json:"quota_size"` + Files QuotaStat `json:"quota_files"` + user *LoggedUser + bucketInfo *garage.BucketInfo + cryptoroot string +} + +//@FIXME Implement quota bursting diff --git a/pim_http.go b/pim_http.go new file mode 100644 index 0000000..e04c702 --- /dev/null +++ b/pim_http.go @@ -0,0 +1,46 @@ +package main + + +import ( + "encoding/json" + "net/http" +) + +func handlePimInspect(w http.ResponseWriter, r *http.Request) { + user := RequireUserHtml(w, r) + if user == nil { + return + } + + pim_ctl, err := NewPimBuilder(user).CheckCryptoRoot().CheckBucket().Build() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + pim_json, err := json.MarshalIndent(pim_ctl, "", " ") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + tKey := getTemplate("pim_inspect.html") + tKey.Execute(w, string(pim_json)) +} + +func handlePimSetup(w http.ResponseWriter, r *http.Request) { + user := RequireUserHtml(w, r) + if user == nil { + return + } + + _, err := NewPimBuilder(user).CheckCryptoRoot().CheckBucket().LdapUpdate().Build() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + user.Capabilities.CanUseEmail = true + + + http.Redirect(w, r, "/pim/inspect", http.StatusFound) +} diff --git a/quotas.go b/quotas.go index 894ea3c..c68f5de 100644 --- a/quotas.go +++ b/quotas.go @@ -9,16 +9,23 @@ import ( "github.com/go-ldap/ldap/v3" ) +// Note: PIM = Personal Information Manager const ( - // --- Default Quota Values --- + // --- Default Quota Values Websites --- QUOTA_WEBSITE_SIZE_DEFAULT = 1024 * 1024 * 50 // 50MB QUOTA_WEBSITE_SIZE_BURSTED = 1024 * 1024 * 200 // 200MB - QUOTA_WEBSITE_OBJECTS = 10000 // 10k objects + QUOTA_WEBSITE_OBJECTS = 10_000 // 10k objects QUOTA_WEBSITE_COUNT = 5 // 5 buckets + // --- Default Quota Values PIM --- + QUOTA_PIM_SIZE_DEFAULT = 1024 * 1024 * 100 // 100MB + QUOTA_PIM_SIZE_BURSTED = 1024 * 1024 * 500 // 500MB + QUOTA_PIM_OBJECTS = 100_000 // 100k objects + // --- Per-user overridable fields --- FIELD_QUOTA_WEBSITE_SIZE_BURSTED = "quota_website_size_bursted" FIELD_QUOTA_WEBSITE_COUNT = "quota_website_count" + FIELD_QUOTA_PIM_SIZE_BURSTED = "quota_pim_size_bursted" ) type UserQuota struct { @@ -26,6 +33,9 @@ type UserQuota struct { WebsiteSizeDefault int64 WebsiteSizeBursted int64 WebsiteObjects int64 + PimSizeDefault int64 + PimSizeBursted int64 + PimObjects int64 } func NewUserQuota() *UserQuota { @@ -34,6 +44,9 @@ func NewUserQuota() *UserQuota { WebsiteSizeDefault: QUOTA_WEBSITE_SIZE_DEFAULT, WebsiteSizeBursted: QUOTA_WEBSITE_SIZE_BURSTED, WebsiteObjects: QUOTA_WEBSITE_OBJECTS, + PimSizeDefault: QUOTA_PIM_SIZE_DEFAULT, + PimSizeBursted: QUOTA_PIM_SIZE_BURSTED, + PimObjects: QUOTA_PIM_OBJECTS, } } @@ -66,6 +79,10 @@ func NewUserQuotaFromEntry(entry *ldap.Entry) *UserQuota { quotas.WebsiteSizeBursted = q } + if q, err := entryToQuota(entry, FIELD_QUOTA_PIM_SIZE_BURSTED); err == nil { + quotas.PimSizeBursted = q + } + return quotas } @@ -78,6 +95,16 @@ func (q *UserQuota) DefaultWebsiteQuota() *garage.UpdateBucketRequestQuotas { return qr } +func (q *UserQuota) DefaultPimQuota() *garage.UpdateBucketRequestQuotas { + qr := garage.NewUpdateBucketRequestQuotas() + + qr.SetMaxSize(q.PimSizeDefault) + qr.SetMaxObjects(q.PimObjects) + + return qr +} + +// Website getters/setters func (q *UserQuota) WebsiteSizeAdjust(sz int64) int64 { if sz < q.WebsiteSizeDefault { return q.WebsiteSizeDefault @@ -100,6 +127,29 @@ func (q *UserQuota) WebsiteSizeBurstedPretty() string { return prettyValue(q.WebsiteSizeBursted) } +// PIM getters/setters +func (q *UserQuota) PimSizeAdjust(sz int64) int64 { + if sz < q.PimSizeDefault { + return q.PimSizeDefault + } else if sz > q.PimSizeBursted { + return q.PimSizeBursted + } else { + return sz + } +} + +func (q *UserQuota) PimObjectAdjust(objs int64) int64 { + if objs > q.PimObjects || objs <= 0 { + return q.PimObjects + } else { + return objs + } +} + +func (q *UserQuota) PimSizeBurstedPretty() string { + return prettyValue(q.PimSizeBursted) +} + // --- A quota stat we can use type QuotaStat struct { Current int64 `json:"current"` diff --git a/templates/home.html b/templates/home.html index dd88d13..8e0a23c 100644 --- a/templates/home.html +++ b/templates/home.html @@ -24,7 +24,7 @@
- Mon espace sur la toile + Mes publications sur la toile
Mes identifiants @@ -33,6 +33,22 @@
+
+
+
+ Mon espace personnel (email, calendrier, contacts, etc.) +
+
+{{if .User.Capabilities.CanUseEmail}} + Accéder à l'interface web + Voir les détails +{{ else }} + Créer mon espace +{{ end }} +
+
+
+ {{if .User.Capabilities.CanInvite}}
diff --git a/templates/pim_inspect.html b/templates/pim_inspect.html new file mode 100644 index 0000000..7d86f9b --- /dev/null +++ b/templates/pim_inspect.html @@ -0,0 +1,16 @@ +{{define "title"}}Configurer mConfigurer mon compte email |{{end}} + +{{define "body"}} +
+

Mon adresse email

+ Menu principal +
+ +
+
+
PAGE DE DEBUG, NON CONFORME POUR UNE MISE EN PRODUCTION
+
+
{{ . }}
+
+ +{{end}}