Compare commits

...

3 commits

Author SHA1 Message Date
aff7efd726
Improve inspect page
Some checks reported errors
continuous-integration/drone/push Build was killed
2024-02-12 19:52:19 +01:00
1a9d750de7
Implement basic PIM management
Some checks reported errors
continuous-integration/drone/push Build was killed
2024-02-12 19:19:41 +01:00
5dd6419d67
update ref conf
Some checks reported errors
continuous-integration/drone/push Build was killed
2024-02-12 10:50:06 +01:00
11 changed files with 445 additions and 26 deletions

View file

@ -1,6 +1,6 @@
{ {
"http_bind_addr": ":9991", "http_bind_addr": "[::]:9991",
"ldap_server_addr": "ldap://127.0.0.1:389", "ldap_server_addr": "ldap://localhost:389",
"base_dn": "dc=bottin,dc=eu", "base_dn": "dc=bottin,dc=eu",
"user_base_dn": "ou=users,dc=bottin,dc=eu", "user_base_dn": "ou=users,dc=bottin,dc=eu",
@ -10,28 +10,27 @@
"invitation_base_dn": "ou=invitations,dc=bottin,dc=eu", "invitation_base_dn": "ou=invitations,dc=bottin,dc=eu",
"invitation_name_attr": "cn", "invitation_name_attr": "cn",
"invited_mail_format": "{}@example.com", "invited_mail_format": "{}@bottin.eu",
"invited_auto_groups": [ "invited_auto_groups": [
"cn=email,ou=groups,dc=bottin,dc=eu" "cn=email,ou=groups,dc=bottin,dc=eu"
], ],
"web_address": "http://guichet.localhost:9991", "web_address": "https://guichet.bottin.eu",
"mail_from": "welcome@example.com", "mail_from": "welcome@bottin.eu",
"smtp_server": "smtp.example.com", "smtp_server": "smtp.bottin.eu",
"smtp_username": "guichet", "smtp_username": "guichet",
"smtp_password": "", "smtp_password": "",
"admin_account": "cn=admin,dc=bottin,dc=eu", "admin_account": "cn=admin,dc=bottin,dc=eu",
"group_can_admin": "gid=admin,ou=groups,dc=bottin,dc=eu", "group_can_admin": "cn=admin,ou=groups,dc=bottin,dc=eu",
"group_can_invite": "", "group_can_invite": "cn=admin,ou=groups,dc=bottin,dc=eu",
"s3_admin_endpoint": "localhost:3903", "s3_admin_endpoint": "localhost:3903",
"s3_admin_token": "GlXP43PWH3LuvEGSNxKYzZCyUss8VqZmarBU+HUlrxw=", "s3_admin_token": "<change me>",
"s3_endpoint": "localhost", "s3_endpoint": "localhost:3900",
"s3_access_key": "", "s3_access_key": "<change me>",
"s3_secret_key": "", "s3_secret_key": "<change me>",
"s3_region": "garage", "s3_region": "garage",
"s3_bucket": "bottin-pictures" "s3_bucket": "bottin-pictures"
} }

View file

@ -61,6 +61,33 @@ func grgCreateBucket(bucket string) (*garage.BucketInfo, error) {
return binfo, nil 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) { func grgAllowKeyOnBucket(bid, gkey string) (*garage.BucketInfo, error) {
client, ctx := gadmin() client, ctx := gadmin()

View file

@ -1,13 +1,13 @@
{ {
"suffix": "dc=bottin,dc=eu", "suffix": "dc=bottin,dc=eu",
"bind": "bottin:389", "bind": "bottin:389",
"consul_host": "consul:8500", "consul_host": "consul:8500",
"acl": [ "acl": [
"ANONYMOUS::bind:*,ou=users,dc=bottin,dc=eu:", "ANONYMOUS::bind:*,ou=users,dc=bottin,dc=eu:",
"ANONYMOUS::bind:cn=admin,dc=bottin,dc=eu:", "ANONYMOUS::bind:cn=admin,dc=bottin,dc=eu:",
"cn=admin,dc=bottin,dc=eu::bind read add modify delete:*:*",
"*,dc=bottin,dc=eu::read:*:* !userpassword", "*,dc=bottin,dc=eu::read:*:* !userpassword",
"cn=admin,dc=bottin,dc=eu::read add modify delete:*:*", "*:cn=admin,ou=groups,dc=bottin,dc=eu:bind read add modify delete:*:*",
"*:cn=admin,ou=groups,dc=bottin,dc=eu:read add modify delete:*:*",
"ANONYMOUS::bind:*,ou=invitations,dc=bottin,dc=eu:", "ANONYMOUS::bind:*,ou=invitations,dc=bottin,dc=eu:",
"*,ou=invitations,dc=bottin,dc=eu::delete:SELF:*", "*,ou=invitations,dc=bottin,dc=eu::delete:SELF:*",
@ -15,5 +15,5 @@
"*,ou=invitations,dc=bottin,dc=eu::modifyAdd:cn=email,ou=groups,dc=bottin,dc=eu:*", "*,ou=invitations,dc=bottin,dc=eu::modifyAdd:cn=email,ou=groups,dc=bottin,dc=eu:*",
"*::read modify:SELF:*" "*::read modify:SELF:*"
] ]
} }

View file

@ -15,7 +15,7 @@ services:
volumes: volumes:
- "./config/bottin.json:/config.json" - "./config/bottin.json:/config.json"
garage: garage:
image: dxflrs/garage:v0.8.2 image: dxflrs/garage:v0.9.1
ports: ports:
- "3900:3900" - "3900:3900"
- "3902:3902" - "3902:3902"

View file

@ -109,14 +109,21 @@ func NewLdapCon() (*ldap.Conn, error) {
// --- Capabilities --- // --- Capabilities ---
type Capabilities struct { type Capabilities struct {
CanAdmin bool CanAdmin bool
CanInvite bool CanInvite bool
CanUseEmail bool
} }
func NewCapabilities(login *LoginStatus, entry *ldap.Entry) *Capabilities { func NewCapabilities(login *LoginStatus, entry *ldap.Entry) *Capabilities {
// Initialize // Initialize
canAdmin := false canAdmin := false
canInvite := false canInvite := false
canUseEmail := false
// Composable logic
hasAeroBucketId := false
hasAeroBucketName := false
hasAeroCryptoRoot := false
// Special case for the "admin" account that is de-facto admin // Special case for the "admin" account that is de-facto admin
canAdmin = strings.EqualFold(login.Info.DN(), config.AdminAccount) canAdmin = strings.EqualFold(login.Info.DN(), config.AdminAccount)
@ -132,12 +139,22 @@ func NewCapabilities(login *LoginStatus, entry *ldap.Entry) *Capabilities {
canAdmin = true 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{ return &Capabilities{
CanAdmin: canAdmin, CanAdmin: canAdmin,
CanInvite: canInvite, CanInvite: canInvite,
CanUseEmail: canUseEmail,
} }
} }
@ -173,6 +190,10 @@ func NewLoggedUser(login *LoginStatus) (*LoggedUser, error) {
FIELD_NAME_PROFILE_PICTURE, FIELD_NAME_PROFILE_PICTURE,
FIELD_QUOTA_WEBSITE_SIZE_BURSTED, FIELD_QUOTA_WEBSITE_SIZE_BURSTED,
FIELD_QUOTA_WEBSITE_COUNT, FIELD_QUOTA_WEBSITE_COUNT,
FIELD_QUOTA_PIM_SIZE_BURSTED,
FIELD_AEROGRAMME_CRYPTOROOT,
FIELD_AEROGRAMME_BUCKET_ID,
FIELD_AEROGRAMME_BUCKET_NAME,
}, },
nil) nil)
@ -204,6 +225,9 @@ func (lu *LoggedUser) WelcomeName() string {
} }
return ret return ret
} }
func (lu *LoggedUser) Email() string {
return lu.Entry.GetAttributeValue("mail")
}
func (lu *LoggedUser) S3KeyInfo() (*garage.KeyInfo, error) { func (lu *LoggedUser) S3KeyInfo() (*garage.KeyInfo, error) {
var err error var err error
var keyPair *garage.KeyInfo var keyPair *garage.KeyInfo

View file

@ -163,6 +163,9 @@ func server(args []string) {
r.HandleFunc("/website/inspect/{bucket}", handleWebsiteInspect) r.HandleFunc("/website/inspect/{bucket}", handleWebsiteInspect)
r.HandleFunc("/website/vhost/{bucket}", handleWebsiteVhost) 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/new_account", handleInviteNewAccount)
r.HandleFunc("/invite/send_code", handleInviteSendCode) r.HandleFunc("/invite/send_code", handleInviteSendCode)
r.HandleFunc("/invitation/{code}", handleInvitationCode) r.HandleFunc("/invitation/{code}", handleInvitationCode)

211
pim_ctrl.go Normal file
View file

@ -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

56
pim_http.go Normal file
View file

@ -0,0 +1,56 @@
package main
import (
"encoding/json"
"net/http"
)
type PimInspectView struct {
User *LoggedUser
Debug string
}
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
}
view := PimInspectView {
User: user,
Debug: string(pim_json),
}
tKey := getTemplate("pim_inspect.html")
tKey.Execute(w, view)
}
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)
}

View file

@ -9,16 +9,23 @@ import (
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
) )
// Note: PIM = Personal Information Manager
const ( const (
// --- Default Quota Values --- // --- Default Quota Values Websites ---
QUOTA_WEBSITE_SIZE_DEFAULT = 1024 * 1024 * 50 // 50MB QUOTA_WEBSITE_SIZE_DEFAULT = 1024 * 1024 * 50 // 50MB
QUOTA_WEBSITE_SIZE_BURSTED = 1024 * 1024 * 200 // 200MB 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 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 --- // --- Per-user overridable fields ---
FIELD_QUOTA_WEBSITE_SIZE_BURSTED = "quota_website_size_bursted" FIELD_QUOTA_WEBSITE_SIZE_BURSTED = "quota_website_size_bursted"
FIELD_QUOTA_WEBSITE_COUNT = "quota_website_count" FIELD_QUOTA_WEBSITE_COUNT = "quota_website_count"
FIELD_QUOTA_PIM_SIZE_BURSTED = "quota_pim_size_bursted"
) )
type UserQuota struct { type UserQuota struct {
@ -26,6 +33,9 @@ type UserQuota struct {
WebsiteSizeDefault int64 WebsiteSizeDefault int64
WebsiteSizeBursted int64 WebsiteSizeBursted int64
WebsiteObjects int64 WebsiteObjects int64
PimSizeDefault int64
PimSizeBursted int64
PimObjects int64
} }
func NewUserQuota() *UserQuota { func NewUserQuota() *UserQuota {
@ -34,6 +44,9 @@ func NewUserQuota() *UserQuota {
WebsiteSizeDefault: QUOTA_WEBSITE_SIZE_DEFAULT, WebsiteSizeDefault: QUOTA_WEBSITE_SIZE_DEFAULT,
WebsiteSizeBursted: QUOTA_WEBSITE_SIZE_BURSTED, WebsiteSizeBursted: QUOTA_WEBSITE_SIZE_BURSTED,
WebsiteObjects: QUOTA_WEBSITE_OBJECTS, 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 quotas.WebsiteSizeBursted = q
} }
if q, err := entryToQuota(entry, FIELD_QUOTA_PIM_SIZE_BURSTED); err == nil {
quotas.PimSizeBursted = q
}
return quotas return quotas
} }
@ -78,6 +95,16 @@ func (q *UserQuota) DefaultWebsiteQuota() *garage.UpdateBucketRequestQuotas {
return qr 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 { func (q *UserQuota) WebsiteSizeAdjust(sz int64) int64 {
if sz < q.WebsiteSizeDefault { if sz < q.WebsiteSizeDefault {
return q.WebsiteSizeDefault return q.WebsiteSizeDefault
@ -100,6 +127,29 @@ func (q *UserQuota) WebsiteSizeBurstedPretty() string {
return prettyValue(q.WebsiteSizeBursted) 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 // --- A quota stat we can use
type QuotaStat struct { type QuotaStat struct {
Current int64 `json:"current"` Current int64 `json:"current"`

View file

@ -24,7 +24,7 @@
<div class="mt-3"> <div class="mt-3">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
Mon espace sur la toile Mes publications sur la toile
</div> </div>
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
<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/configure">Mes identifiants</a>
@ -33,6 +33,22 @@
</div> </div>
</div> </div>
<div class="mt-3">
<div class="card">
<div class="card-header">
Mon espace personnel (email, calendrier, contacts, etc.)
</div>
<div class="list-group list-group-flush">
{{if .User.Capabilities.CanUseEmail}}
<a class="list-group-item list-group-item-action disabled" href="#">Accéder à l'interface web</a>
<a class="list-group-item list-group-item-action" href="/pim/inspect">Voir les détails</a>
{{ else }}
<a class="list-group-item list-group-item-action" href="/pim/setup">Créer mon espace</a>
{{ end }}
</div>
</div>
</div>
{{if .User.Capabilities.CanInvite}} {{if .User.Capabilities.CanInvite}}
<div class="card mt-3"> <div class="card mt-3">
<div class="card-header"> <div class="card-header">

View file

@ -0,0 +1,33 @@
{{define "title"}}Configurer mConfigurer mon compte email |{{end}}
{{define "body"}}
<div class="d-flex">
<h4>Mon adresse email</h4>
<a class="ml-auto btn btn-info" href="/">Menu principal</a>
</div>
<div class="row">
<div class="col-md-12 mt-3">
<div class="alert alert-danger">PAGE DE DEBUG, NON CONFORME POUR UNE MISE EN PRODUCTION</div>
</div>
<pre>
--- login info ---
email: {{ .User.Email }}
username: {{ .User.Login.Info.Username }}
password: ********
--- autodiscovery ---
Implemented: RFC6186 DNS SRV autodiscovery + Thunderbird Autoconfig
Not implemented: Microsoft Autodiscover + Apple Mobileconfig
--- manual configuration ---
IMAP: imap.saint-ex.deuxfleurs.org:993 (TLS)
SMTP: smtp.saint-ex.deuxfleurs.org:465 (TLS)
--- dump PIM controller ---
{{ .Debug }}
</pre>
</div>
{{end}}