Implement list visibility

Also consolidate list permissions columns into one default_access
column.
This commit is contained in:
Adnan Maolood 2022-06-14 06:47:26 -04:00 committed by Drew DeVault
parent 2ae65a9e81
commit 02395e0471
24 changed files with 452 additions and 562 deletions

View File

@ -24,25 +24,23 @@ const (
) )
type MailingList struct { type MailingList struct {
ID int `json:"id"` ID int `json:"id"`
Created time.Time `json:"created"` Created time.Time `json:"created"`
Updated time.Time `json:"updated"` Updated time.Time `json:"updated"`
Name string `json:"name"` Name string `json:"name"`
Description *string `json:"description"` Description *string `json:"description"`
Importing bool `json:"importing"` Visibility Visibility `json:"visibility"`
Importing bool `json:"importing"`
OwnerID int OwnerID int
RawPermitMime string RawPermitMime string
RawRejectMime string RawRejectMime string
Permissions int Access int
DefaultAccess uint
AccessID *int AccessID *int
SubscriptionID *int SubscriptionID *int
RawNonsubscriber uint
RawSubscriber uint
RawIdentified uint
alias string alias string
fields *database.ModelFields fields *database.ModelFields
} }
@ -61,30 +59,12 @@ func (list *MailingList) RejectMime() []string {
return strings.Split(list.RawRejectMime, ",") return strings.Split(list.RawRejectMime, ",")
} }
func (list *MailingList) Nonsubscriber() *GeneralACL { func (list *MailingList) DefaultACL() *GeneralACL {
return &GeneralACL{ return &GeneralACL{
Browse: list.RawNonsubscriber&ACCESS_BROWSE > 0, Browse: list.DefaultAccess&ACCESS_BROWSE > 0,
Reply: list.RawNonsubscriber&ACCESS_REPLY > 0, Reply: list.DefaultAccess&ACCESS_REPLY > 0,
Post: list.RawNonsubscriber&ACCESS_POST > 0, Post: list.DefaultAccess&ACCESS_POST > 0,
Moderate: list.RawNonsubscriber&ACCESS_MODERATE > 0, Moderate: list.DefaultAccess&ACCESS_MODERATE > 0,
}
}
func (list *MailingList) Subscriber() *GeneralACL {
return &GeneralACL{
Browse: list.RawSubscriber&ACCESS_BROWSE > 0,
Reply: list.RawSubscriber&ACCESS_REPLY > 0,
Post: list.RawSubscriber&ACCESS_POST > 0,
Moderate: list.RawSubscriber&ACCESS_MODERATE > 0,
}
}
func (list *MailingList) Identified() *GeneralACL {
return &GeneralACL{
Browse: list.RawIdentified&ACCESS_BROWSE > 0,
Reply: list.RawIdentified&ACCESS_REPLY > 0,
Post: list.RawIdentified&ACCESS_POST > 0,
Moderate: list.RawIdentified&ACCESS_MODERATE > 0,
} }
} }
@ -112,11 +92,10 @@ func (list *MailingList) Fields() *database.ModelFields {
{"name", "name", &list.Name}, {"name", "name", &list.Name},
{"description", "description", &list.Description}, {"description", "description", &list.Description},
{"import_in_progress", "importing", &list.Importing}, {"import_in_progress", "importing", &list.Importing},
{"permit_mimetypes", "permit_mime", &list.RawPermitMime}, {"permit_mimetypes", "permitMime", &list.RawPermitMime},
{"reject_mimetypes", "reject_mime", &list.RawRejectMime}, {"reject_mimetypes", "rejectMime", &list.RawRejectMime},
{"nonsubscriber_permissions", "nonsubscriber", &list.RawNonsubscriber}, {"visibility", "visibility", &list.Visibility},
{"subscriber_permissions", "subscriber", &list.RawSubscriber}, {"default_access", "defaultACL", &list.DefaultAccess},
{"account_permissions", "identified", &list.RawIdentified},
// Always fetch: // Always fetch:
{"id", "", &list.ID}, {"id", "", &list.ID},
@ -151,12 +130,9 @@ func (list *MailingList) QueryWithCursor(ctx context.Context,
Column(`COALESCE( Column(`COALESCE(
access.permissions, access.permissions,
CASE WHEN list.owner_id = ? CASE WHEN list.owner_id = ?
THEN ? THEN ?
ELSE CASE WHEN sub.id IS NOT NULL ELSE list.default_access
THEN list.subscriber_permissions END)`,
ELSE null END
END,
list.nonsubscriber_permissions | list.account_permissions)`,
user.UserID, ACCESS_ALL). user.UserID, ACCESS_ALL).
Column(`access.id`). Column(`access.id`).
Column(`sub.id`). Column(`sub.id`).
@ -172,7 +148,7 @@ func (list *MailingList) QueryWithCursor(ctx context.Context,
for rows.Next() { for rows.Next() {
var list MailingList var list MailingList
if err := rows.Scan(append(database.Scan(ctx, &list), if err := rows.Scan(append(database.Scan(ctx, &list),
&list.Permissions, &list.Access,
&list.AccessID, &list.AccessID,
&list.SubscriptionID)...); err != nil { &list.SubscriptionID)...); err != nil {
panic(err) panic(err)

View File

@ -92,6 +92,12 @@ type Mailbox implements Entity {
address: String! address: String!
} }
enum Visibility {
PUBLIC
UNLISTED
PRIVATE
}
type MailingList { type MailingList {
id: Int! id: Int!
created: Time! created: Time!
@ -101,6 +107,7 @@ type MailingList {
# Markdown # Markdown
description: String description: String
visibility: Visibility!
""" """
List of globs for permitted or rejected mimetypes on this list List of globs for permitted or rejected mimetypes on this list
@ -134,12 +141,8 @@ type MailingList {
"Access control list entries for this mailing list" "Access control list entries for this mailing list"
acl(cursor: Cursor): MailingListACLCursor! @access(scope: ACLS, kind: RO) acl(cursor: Cursor): MailingListACLCursor! @access(scope: ACLS, kind: RO)
"Permissions which apply to any non-subscriber"
nonsubscriber: GeneralACL! defaultACL: GeneralACL!
"Permissions which apply to any subscriber"
subscriber: GeneralACL!
"Permissions which apply to any authenticated account holder"
identified: GeneralACL!
""" """
Returns a list of mailing list webhook subscriptions. For clients Returns a list of mailing list webhook subscriptions. For clients
@ -644,6 +647,7 @@ type Query {
# TODO: Allow users to change the name of a mailing list # TODO: Allow users to change the name of a mailing list
input MailingListInput { input MailingListInput {
description: String description: String
visibility: Visibility
""" """
List of globs for permitted or rejected mimetypes on this list List of globs for permitted or rejected mimetypes on this list
@ -677,7 +681,8 @@ type Mutation {
"Creates a new mailing list" "Creates a new mailing list"
createMailingList( createMailingList(
name: String!, name: String!,
description: String): MailingList! @access(scope: LISTS, kind: RW) description: String,
visibility: Visibility!): MailingList! @access(scope: LISTS, kind: RW)
"Updates a mailing list." "Updates a mailing list."
updateMailingList( updateMailingList(

View File

@ -208,7 +208,7 @@ func (r *mailingListResolver) Access(ctx context.Context, obj *model.MailingList
if obj.AccessID != nil { if obj.AccessID != nil {
return loaders.ForContext(ctx).ACLsByID.Load(*obj.AccessID) return loaders.ForContext(ctx).ACLsByID.Load(*obj.AccessID)
} }
p := obj.Permissions p := obj.Access
return &model.GeneralACL{ return &model.GeneralACL{
Browse: p&model.ACCESS_BROWSE != 0, Browse: p&model.ACCESS_BROWSE != 0,
Reply: p&model.ACCESS_REPLY != 0, Reply: p&model.ACCESS_REPLY != 0,
@ -511,7 +511,7 @@ func (r *mailingListWebhookSubscriptionResolver) List(ctx context.Context, obj *
return loaders.ForContext(ctx).MailingListsByID.Load(obj.ListID) return loaders.ForContext(ctx).MailingListsByID.Load(obj.ListID)
} }
func (r *mutationResolver) CreateMailingList(ctx context.Context, name string, description *string) (*model.MailingList, error) { func (r *mutationResolver) CreateMailingList(ctx context.Context, name string, description *string, visibility model.Visibility) (*model.MailingList, error) {
valid := valid.New(ctx) valid := valid.New(ctx)
valid.Expect(listNameRE.MatchString(name), "Name must match %s", listNameRE.String()). valid.Expect(listNameRE.MatchString(name), "Name must match %s", listNameRE.String()).
WithField("name"). WithField("name").
@ -529,21 +529,20 @@ func (r *mutationResolver) CreateMailingList(ctx context.Context, name string, d
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error { if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
row := tx.QueryRowContext(ctx, ` row := tx.QueryRowContext(ctx, `
INSERT INTO list ( INSERT INTO list (
created, updated, name, description, owner_id created, updated, name, description, visibility, owner_id
) VALUES ( ) VALUES (
NOW() at time zone 'utc', NOW() at time zone 'utc',
NOW() at time zone 'utc', NOW() at time zone 'utc',
$1, $2, $3 $1, $2, $3, $4
) RETURNING ) RETURNING
id, created, updated, name, description, owner_id, id, created, updated, name, description, visibility, owner_id,
permit_mimetypes, reject_mimetypes, permit_mimetypes, reject_mimetypes, default_access;
nonsubscriber_permissions, subscriber_permissions, account_permissions; `, name, description, visibility.String(), auth.ForContext(ctx).UserID)
`, name, description, auth.ForContext(ctx).UserID)
if err := row.Scan(&list.ID, &list.Created, &list.Updated, &list.Name, if err := row.Scan(&list.ID, &list.Created, &list.Updated, &list.Name,
&list.Description, &list.OwnerID, &list.Description, &list.Visibility, &list.OwnerID,
&list.RawPermitMime, &list.RawRejectMime, &list.RawPermitMime, &list.RawRejectMime,
&list.RawNonsubscriber, &list.RawSubscriber, &list.RawIdentified); err != nil { &list.DefaultAccess); err != nil {
if err, ok := err.(*pq.Error); ok && if err, ok := err.(*pq.Error); ok &&
err.Code == "23505" && // unique_violation err.Code == "23505" && // unique_violation
err.Constraint == "uq_list_owner_id_name" { err.Constraint == "uq_list_owner_id_name" {
@ -551,7 +550,7 @@ func (r *mutationResolver) CreateMailingList(ctx context.Context, name string, d
} }
return err return err
} }
list.Permissions = model.ACCESS_ALL list.Access = model.ACCESS_ALL
_, err := tx.ExecContext(ctx, ` _, err := tx.ExecContext(ctx, `
INSERT INTO subscription ( INSERT INTO subscription (
@ -583,6 +582,9 @@ func (r *mutationResolver) UpdateMailingList(ctx context.Context, id int, input
WithField("description") WithField("description")
query = query.Set("description", desc) query = query.Set("description", desc)
}) })
valid.OptionalString("visibility", func(visibility string) {
query = query.Set("visibility", visibility)
})
mime := func(name string) { mime := func(name string) {
valid.Optional(name+"Mime", func(object interface{}) { valid.Optional(name+"Mime", func(object interface{}) {
list, ok := object.([]interface{}) list, ok := object.([]interface{})
@ -613,19 +615,18 @@ func (r *mutationResolver) UpdateMailingList(ctx context.Context, id int, input
Where(`list.id = ? AND list.owner_id = ?`, Where(`list.id = ? AND list.owner_id = ?`,
id, auth.ForContext(ctx).UserID). id, auth.ForContext(ctx).UserID).
Suffix(`RETURNING Suffix(`RETURNING
id, created, updated, name, description, owner_id, id, created, updated, name, description, visibility, owner_id,
permit_mimetypes, reject_mimetypes, permit_mimetypes, reject_mimetypes, default_access`).
nonsubscriber_permissions, subscriber_permissions, account_permissions`).
RunWith(tx). RunWith(tx).
QueryRowContext(ctx) QueryRowContext(ctx)
if err := row.Scan(&list.ID, &list.Created, &list.Updated, &list.Name, if err := row.Scan(&list.ID, &list.Created, &list.Updated, &list.Name,
&list.Description, &list.OwnerID, &list.Description, &list.Visibility, &list.OwnerID,
&list.RawPermitMime, &list.RawRejectMime, &list.RawPermitMime, &list.RawRejectMime,
&list.RawNonsubscriber, &list.RawSubscriber, &list.RawIdentified); err != nil { &list.DefaultAccess); err != nil {
return err return err
} }
list.Permissions = model.ACCESS_ALL list.Access = model.ACCESS_ALL
return nil return nil
}); err != nil { }); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@ -651,17 +652,15 @@ func (r *mutationResolver) DeleteMailingList(ctx context.Context, id int) (*mode
DELETE FROM list DELETE FROM list
WHERE id = $1 AND owner_id = $2 WHERE id = $1 AND owner_id = $2
RETURNING RETURNING
id, created, updated, name, description, owner_id, id, created, updated, name, description, visibility, owner_id,
permit_mimetypes, reject_mimetypes, permit_mimetypes, reject_mimetypes, default_access;`,
nonsubscriber_permissions, subscriber_permissions, account_permissions;`,
id, auth.ForContext(ctx).UserID) id, auth.ForContext(ctx).UserID)
if err := row.Scan(&list.ID, &list.Created, &list.Updated, &list.Name, if err := row.Scan(&list.ID, &list.Created, &list.Updated, &list.Name,
&list.Description, &list.OwnerID, &list.Description, &list.Visibility, &list.OwnerID,
&list.RawPermitMime, &list.RawRejectMime, &list.RawPermitMime, &list.RawRejectMime, &list.DefaultAccess); err != nil {
&list.RawNonsubscriber, &list.RawSubscriber, &list.RawIdentified); err != nil {
return err return err
} }
list.Permissions = model.ACCESS_ALL list.Access = model.ACCESS_ALL
// We need to do this here so that it picks up the subscription list // We need to do this here so that it picks up the subscription list
// before the cascade sets their list_id columns to null. // before the cascade sets their list_id columns to null.
@ -771,25 +770,19 @@ func (r *mutationResolver) UpdateMailingListACL(ctx context.Context, listID int,
bits := ACLInputBits(input) bits := ACLInputBits(input)
var list model.MailingList var list model.MailingList
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error { if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
// TODO: Update me after unifying the ACL columns
row := tx.QueryRowContext(ctx, ` row := tx.QueryRowContext(ctx, `
UPDATE list SET UPDATE list SET default_access = $1
nonsubscriber_permissions = $1,
subscriber_permissions = $1,
account_permissions = $1
WHERE id = $2 AND owner_id = $3 WHERE id = $2 AND owner_id = $3
RETURNING RETURNING
id, created, updated, name, description, owner_id, id, created, updated, name, description, visibility, owner_id,
permit_mimetypes, reject_mimetypes, permit_mimetypes, reject_mimetypes, default_access;
nonsubscriber_permissions, subscriber_permissions, account_permissions;
`, bits, listID, auth.ForContext(ctx).UserID) `, bits, listID, auth.ForContext(ctx).UserID)
if err := row.Scan(&list.ID, &list.Created, &list.Updated, &list.Name, if err := row.Scan(&list.ID, &list.Created, &list.Updated, &list.Name,
&list.Description, &list.OwnerID, &list.Description, &list.Visibility, &list.OwnerID,
&list.RawPermitMime, &list.RawRejectMime, &list.RawPermitMime, &list.RawRejectMime, &list.DefaultAccess); err != nil {
&list.RawNonsubscriber, &list.RawSubscriber, &list.RawIdentified); err != nil {
return err return err
} }
list.Permissions = model.ACCESS_ALL list.Access = model.ACCESS_ALL
return nil return nil
}); err != nil { }); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@ -953,10 +946,7 @@ func (r *mutationResolver) MailingListSubscribe(ctx context.Context, listID int)
WHERE list.id = $2 AND ( WHERE list.id = $2 AND (
list.owner_id = $1 OR list.owner_id = $1 OR
(access.id IS NOT NULL AND access.permissions & $3 > 0) OR (access.id IS NOT NULL AND access.permissions & $3 > 0) OR
(access.id IS NULL AND ( (access.id IS NULL AND list.default_access & $3 > 0)
list.subscriber_permissions |
list.account_permissions |
list.nonsubscriber_permissions) & $3 > 0)
) )
) INSERT INTO subscription ( ) INSERT INTO subscription (
created, updated, user_id, list_id created, updated, user_id, list_id
@ -1829,27 +1819,9 @@ func (r *userResolver) Lists(ctx context.Context, obj *model.User, cursor *corem
Where(sq.And{ Where(sq.And{
sq.Expr(`list.owner_id = ?`, obj.ID), sq.Expr(`list.owner_id = ?`, obj.ID),
sq.Or{ sq.Or{
// List owner, or
sq.Expr(`list.owner_id = ?`, user.UserID), sq.Expr(`list.owner_id = ?`, user.UserID),
// ACL entry exists, or sq.Expr(`list.visibility != 'PRIVATE'`),
sq.And{ sq.Expr(`access.permissions > 0`),
sq.Expr(`access.id IS NOT NULL`),
sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Subscribers, or
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`sub.id IS NULL`),
sq.Expr(`list.nonsubscriber_permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Or:
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`
(list.subscriber_permissions | list.account_permissions) & ? > 0`,
model.ACCESS_BROWSE,
),
},
}, },
}) })
lists, cursor = list.QueryWithCursor(ctx, tx, query, cursor) lists, cursor = list.QueryWithCursor(ctx, tx, query, cursor)
@ -1880,33 +1852,12 @@ func (r *userResolver) Emails(ctx context.Context, obj *model.User, cursor *core
LeftJoin(`access ON LeftJoin(`access ON
access.list_id = list.id AND access.list_id = list.id AND
access.user_id = ?`, user.UserID). access.user_id = ?`, user.UserID).
LeftJoin(`subscription sub ON
sub.list_id = list.id AND
sub.user_id = ?`, user.UserID).
Where(sq.And{ Where(sq.And{
sq.Expr(`mail.sender_id = ?`, obj.ID), sq.Expr(`mail.sender_id = ?`, obj.ID),
sq.Or{ sq.Or{
// List owner, or
sq.Expr(`list.owner_id = ?`, user.UserID), sq.Expr(`list.owner_id = ?`, user.UserID),
// ACL entry exists, or sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
sq.And{ sq.Expr(`list.default_access & ? > 0`, model.ACCESS_BROWSE),
sq.Expr(`access.id IS NOT NULL`),
sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Subscribers, or
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`sub.id IS NULL`),
sq.Expr(`list.nonsubscriber_permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Or:
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`
(list.subscriber_permissions | list.account_permissions) & ? > 0`,
model.ACCESS_BROWSE,
),
},
}, },
}) })
emails, cursor = email.QueryWithCursor(ctx, tx, query, cursor) emails, cursor = email.QueryWithCursor(ctx, tx, query, cursor)
@ -1937,34 +1888,13 @@ func (r *userResolver) Threads(ctx context.Context, obj *model.User, cursor *cor
LeftJoin(`access ON LeftJoin(`access ON
access.list_id = list.id AND access.list_id = list.id AND
access.user_id = ?`, user.UserID). access.user_id = ?`, user.UserID).
LeftJoin(`subscription sub ON
sub.list_id = list.id AND
sub.user_id = ?`, user.UserID).
Where(sq.And{ Where(sq.And{
sq.Expr(`mail.sender_id = ?`, obj.ID), sq.Expr(`mail.sender_id = ?`, obj.ID),
sq.Expr(`mail.thread_id IS NULL`), sq.Expr(`mail.thread_id IS NULL`),
sq.Or{ sq.Or{
// List owner, or
sq.Expr(`list.owner_id = ?`, user.UserID), sq.Expr(`list.owner_id = ?`, user.UserID),
// ACL entry exists, or sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
sq.And{ sq.Expr(`list.default_access & ? > 0`, model.ACCESS_BROWSE),
sq.Expr(`access.id IS NOT NULL`),
sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Subscribers, or
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`sub.id IS NULL`),
sq.Expr(`list.nonsubscriber_permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Or:
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`
(list.subscriber_permissions | list.account_permissions) & ? > 0`,
model.ACCESS_BROWSE,
),
},
}, },
}) })
threads, cursor = thread.QueryWithCursor(ctx, tx, query, cursor) threads, cursor = thread.QueryWithCursor(ctx, tx, query, cursor)
@ -1996,33 +1926,12 @@ func (r *userResolver) Patches(ctx context.Context, obj *model.User, cursor *cor
LeftJoin(`access ON LeftJoin(`access ON
access.list_id = list.id AND access.list_id = list.id AND
access.user_id = ?`, user.UserID). access.user_id = ?`, user.UserID).
LeftJoin(`subscription sub ON
sub.list_id = list.id AND
sub.user_id = ?`, user.UserID).
Where(sq.And{ Where(sq.And{
sq.Expr(`email.sender_id = ?`, obj.ID), sq.Expr(`email.sender_id = ?`, obj.ID),
sq.Or{ sq.Or{
// List owner, or
sq.Expr(`list.owner_id = ?`, user.UserID), sq.Expr(`list.owner_id = ?`, user.UserID),
// ACL entry exists, or sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
sq.And{ sq.Expr(`list.default_access & ? > 0`, model.ACCESS_BROWSE),
sq.Expr(`access.id IS NOT NULL`),
sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Subscribers, or
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`sub.id IS NULL`),
sq.Expr(`list.nonsubscriber_permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Or:
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`
(list.subscriber_permissions | list.account_permissions) & ? > 0`,
model.ACCESS_BROWSE,
),
},
}, },
}) })
patches, cursor = patch.QueryWithCursor(ctx, tx, query, cursor) patches, cursor = patch.QueryWithCursor(ctx, tx, query, cursor)

View File

@ -190,39 +190,18 @@ func fetchMailingListsByID(ctx context.Context) func(ids []int) ([]*model.Mailin
Column(`COALESCE( Column(`COALESCE(
access.permissions, access.permissions,
CASE WHEN list.owner_id = ? CASE WHEN list.owner_id = ?
THEN ? THEN ?
ELSE CASE WHEN sub.id IS NOT NULL ELSE list.default_access
THEN list.subscriber_permissions END)`,
ELSE null END
END,
list.nonsubscriber_permissions | list.account_permissions)`,
user.UserID, model.ACCESS_ALL). user.UserID, model.ACCESS_ALL).
Column(`access.id`). Column(`access.id`).
Column(`sub.id`). Column(`sub.id`).
Where(sq.And{ Where(sq.And{
sq.Expr(`list.id = ANY(?)`, pq.Array(ids)), sq.Expr(`list.id = ANY(?)`, pq.Array(ids)),
sq.Or{ sq.Or{
// List owner, or
sq.Expr(`list.owner_id = ?`, user.UserID), sq.Expr(`list.owner_id = ?`, user.UserID),
// ACL entry exists, or sq.Expr(`list.visibility != 'PRIVATE'`),
sq.And{ sq.Expr(`access.permissions > 0`),
sq.Expr(`access.id IS NOT NULL`),
sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Subscribers, or
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`sub.id IS NULL`),
sq.Expr(`list.nonsubscriber_permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Or:
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`
(list.subscriber_permissions | list.account_permissions) & ? > 0`,
model.ACCESS_BROWSE,
),
},
}, },
}) })
if rows, err = query.RunWith(tx).QueryContext(ctx); err != nil { if rows, err = query.RunWith(tx).QueryContext(ctx); err != nil {
@ -235,7 +214,7 @@ func fetchMailingListsByID(ctx context.Context) func(ids []int) ([]*model.Mailin
list := model.MailingList{} list := model.MailingList{}
if err := rows.Scan(append( if err := rows.Scan(append(
database.Scan(ctx, &list), database.Scan(ctx, &list),
&list.Permissions, &list.Access,
&list.AccessID, &list.AccessID,
&list.SubscriptionID, &list.SubscriptionID,
)...); err != nil { )...); err != nil {
@ -353,37 +332,16 @@ func fetchMailingListsByOwnerName(ctx context.Context) func(names [][2]string) (
Column(`COALESCE( Column(`COALESCE(
access.permissions, access.permissions,
CASE WHEN list.owner_id = ? CASE WHEN list.owner_id = ?
THEN ? THEN ?
ELSE CASE WHEN sub.id IS NOT NULL ELSE list.default_access
THEN list.subscriber_permissions END)`,
ELSE null END
END,
list.nonsubscriber_permissions | list.account_permissions)`,
user.UserID, model.ACCESS_ALL). user.UserID, model.ACCESS_ALL).
Column(`access.id`). Column(`access.id`).
Column(`sub.id`). Column(`sub.id`).
Where(sq.Or{ Where(sq.Or{
// List owner, or
sq.Expr(`list.owner_id = ?`, user.UserID), sq.Expr(`list.owner_id = ?`, user.UserID),
// ACL entry exists, or sq.Expr(`list.visibility != 'PRIVATE'`),
sq.And{ sq.Expr(`access.permissions > 0`),
sq.Expr(`access.id IS NOT NULL`),
sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Subscribers, or
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`sub.id IS NULL`),
sq.Expr(`list.nonsubscriber_permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Or:
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`
(list.subscriber_permissions | list.account_permissions) & ? > 0`,
model.ACCESS_BROWSE,
),
},
}) })
if rows, err = query.RunWith(tx).QueryContext(ctx); err != nil { if rows, err = query.RunWith(tx).QueryContext(ctx); err != nil {
panic(err) panic(err)
@ -399,7 +357,7 @@ func fetchMailingListsByOwnerName(ctx context.Context) func(names [][2]string) (
if err := rows.Scan(append( if err := rows.Scan(append(
database.Scan(ctx, &list), database.Scan(ctx, &list),
&ownerName, &ownerName,
&list.Permissions, &list.Access,
&list.AccessID, &list.AccessID,
&list.SubscriptionID)...); err != nil { &list.SubscriptionID)...); err != nil {
panic(err) panic(err)
@ -446,27 +404,9 @@ func fetchEmailsByID(ctx context.Context) func(ids []int) ([]*model.Email, []err
Where(sq.And{ Where(sq.And{
sq.Expr(`email.id = ANY(?)`, pq.Array(ids)), sq.Expr(`email.id = ANY(?)`, pq.Array(ids)),
sq.Or{ sq.Or{
// List owner, or
sq.Expr(`list.owner_id = ?`, user.UserID), sq.Expr(`list.owner_id = ?`, user.UserID),
// ACL entry exists, or sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
sq.And{ sq.Expr(`list.default_access & ? > 0`, model.ACCESS_BROWSE),
sq.Expr(`access.id IS NOT NULL`),
sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Subscribers, or
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`sub.id IS NULL`),
sq.Expr(`list.nonsubscriber_permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Or:
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`
(list.subscriber_permissions | list.account_permissions) & ? > 0`,
model.ACCESS_BROWSE,
),
},
}, },
}) })
if rows, err = query.RunWith(tx).QueryContext(ctx); err != nil { if rows, err = query.RunWith(tx).QueryContext(ctx); err != nil {
@ -523,27 +463,9 @@ func fetchEmailsByMessageID(ctx context.Context) func(ids []string) ([]*model.Em
Where(sq.And{ Where(sq.And{
sq.Expr(`email.message_id = ANY(?)`, pq.Array(ids)), sq.Expr(`email.message_id = ANY(?)`, pq.Array(ids)),
sq.Or{ sq.Or{
// List owner, or
sq.Expr(`list.owner_id = ?`, user.UserID), sq.Expr(`list.owner_id = ?`, user.UserID),
// ACL entry exists, or sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
sq.And{ sq.Expr(`list.default_access & ? > 0`, model.ACCESS_BROWSE),
sq.Expr(`access.id IS NOT NULL`),
sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Subscribers, or
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`sub.id IS NULL`),
sq.Expr(`list.nonsubscriber_permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Or:
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`
(list.subscriber_permissions | list.account_permissions) & ? > 0`,
model.ACCESS_BROWSE,
),
},
}, },
}) })
if rows, err = query.RunWith(tx).QueryContext(ctx); err != nil { if rows, err = query.RunWith(tx).QueryContext(ctx); err != nil {
@ -688,27 +610,9 @@ func fetchPatchsetsByID(ctx context.Context) func(ids []int) ([]*model.Patchset,
Where(sq.And{ Where(sq.And{
sq.Expr(`patch.id = ANY(?)`, pq.Array(ids)), sq.Expr(`patch.id = ANY(?)`, pq.Array(ids)),
sq.Or{ sq.Or{
// List owner, or
sq.Expr(`list.owner_id = ?`, user.UserID), sq.Expr(`list.owner_id = ?`, user.UserID),
// ACL entry exists, or sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
sq.And{ sq.Expr(`list.default_access & ? > 0`, model.ACCESS_BROWSE),
sq.Expr(`access.id IS NOT NULL`),
sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Subscribers, or
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`sub.id IS NULL`),
sq.Expr(`list.nonsubscriber_permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Or:
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`
(list.subscriber_permissions | list.account_permissions) & ? > 0`,
model.ACCESS_BROWSE,
),
},
}, },
}) })
if rows, err = query.RunWith(tx).QueryContext(ctx); err != nil { if rows, err = query.RunWith(tx).QueryContext(ctx); err != nil {

View File

@ -94,12 +94,8 @@ func main() {
LEFT JOIN subscription sub ON sub.list_id = list.id LEFT JOIN subscription sub ON sub.list_id = list.id
WHERE email.id = $1 OR email.thread_id = $1 AND ( WHERE email.id = $1 OR email.thread_id = $1 AND (
list.owner_id = $2 OR list.owner_id = $2 OR
(access.id IS NOT NULL AND access.permissions & $3 > 0) OR access.permissions & $3 > 0 OR
(access.id IS NULL list.default_access & $3 > 0)
AND sub.id IS NULL
AND list.nonsubscriber_permissions & $3 > 0) OR
(access.id IS NULL AND
(list.subscriber_permissions | list.account_permissions) & $3 > 0))
ORDER BY email.id ORDER BY email.id
`, id, auth.ForContext(r.Context()).UserID, model.ACCESS_BROWSE) `, id, auth.ForContext(r.Context()).UserID, model.ACCESS_BROWSE)
if err != nil { if err != nil {
@ -131,12 +127,8 @@ func main() {
LEFT JOIN subscription sub ON sub.list_id = list.id LEFT JOIN subscription sub ON sub.list_id = list.id
WHERE email.patchset_id = $1 AND email.is_patch AND ( WHERE email.patchset_id = $1 AND email.is_patch AND (
list.owner_id = $2 OR list.owner_id = $2 OR
(access.id IS NOT NULL AND access.permissions & $3 > 0) OR access.permissions & $3 > 0 OR
(access.id IS NULL list.default_access & $3 > 0)
AND sub.id IS NULL
AND list.nonsubscriber_permissions & $3 > 0) OR
(access.id IS NULL AND
(list.subscriber_permissions | list.account_permissions) & $3 > 0))
ORDER BY email.patch_index, email.id ORDER BY email.patch_index, email.id
`, id, auth.ForContext(r.Context()).UserID, model.ACCESS_BROWSE) `, id, auth.ForContext(r.Context()).UserID, model.ACCESS_BROWSE)
if err != nil { if err != nil {
@ -179,12 +171,8 @@ func main() {
LEFT JOIN subscription sub ON sub.list_id = list.id LEFT JOIN subscription sub ON sub.list_id = list.id
WHERE email.list_id = $1 AND email.created >= $2 AND ( WHERE email.list_id = $1 AND email.created >= $2 AND (
list.owner_id = $3 OR list.owner_id = $3 OR
(access.id IS NOT NULL AND access.permissions & $4 > 0) OR access.permissions & $4 > 0 OR
(access.id IS NULL list.default_access & $4 > 0)
AND sub.id IS NULL
AND list.nonsubscriber_permissions & $4 > 0) OR
(access.id IS NULL AND
(list.subscriber_permissions | list.account_permissions) & $4 > 0))
ORDER BY email.created ORDER BY email.created
`, id, since, auth.ForContext(r.Context()).UserID, model.ACCESS_BROWSE) `, id, since, auth.ForContext(r.Context()).UserID, model.ACCESS_BROWSE)
if err != nil { if err != nil {

View File

@ -62,9 +62,9 @@ func DeliverLegacyUserListEvent(
Updated: list.Updated, Updated: list.Updated,
Description: list.Description, Description: list.Description,
} }
payload.Permissions.Nonsubscriber = encodePermissions(list.RawNonsubscriber) payload.Permissions.Nonsubscriber = encodePermissions(list.DefaultAccess)
payload.Permissions.Subscriber = encodePermissions(list.RawSubscriber) payload.Permissions.Subscriber = encodePermissions(list.DefaultAccess)
payload.Permissions.Account = encodePermissions(list.RawIdentified) payload.Permissions.Account = encodePermissions(list.DefaultAccess)
// TODO: User groups // TODO: User groups
user := auth.ForContext(ctx) user := auth.ForContext(ctx)
@ -100,9 +100,9 @@ func DeliverLegacyListEvent(
Updated: list.Updated, Updated: list.Updated,
Description: list.Description, Description: list.Description,
} }
payload.Permissions.Nonsubscriber = encodePermissions(list.RawNonsubscriber) payload.Permissions.Nonsubscriber = encodePermissions(list.DefaultAccess)
payload.Permissions.Subscriber = encodePermissions(list.RawSubscriber) payload.Permissions.Subscriber = encodePermissions(list.DefaultAccess)
payload.Permissions.Account = encodePermissions(list.RawIdentified) payload.Permissions.Account = encodePermissions(list.DefaultAccess)
// TODO: User groups // TODO: User groups
user := auth.ForContext(ctx) user := auth.ForContext(ctx)

View File

@ -40,27 +40,9 @@ func deliverListWebhook(ctx context.Context, listID int,
Where(sq.And{ Where(sq.And{
sq.Expr(`sub.list_id = ?`, listID), sq.Expr(`sub.list_id = ?`, listID),
sq.Or{ sq.Or{
// List owner, or
sq.Expr(`list.owner_id = sub.user_id`), sq.Expr(`list.owner_id = sub.user_id`),
// ACL entry exists, or sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
sq.And{ sq.Expr(`list.default_access & ? > 0`, model.ACCESS_BROWSE),
sq.Expr(`access.id IS NOT NULL`),
sq.Expr(`access.permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Subscribers, or
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`lsub.id IS NULL`),
sq.Expr(`list.nonsubscriber_permissions & ? > 0`, model.ACCESS_BROWSE),
},
// Or:
sq.And{
sq.Expr(`access.id IS NULL`),
sq.Expr(`
(list.subscriber_permissions | list.account_permissions) & ? > 0`,
model.ACCESS_BROWSE,
),
},
}, },
}) })
q.Schedule(ctx, query, "list", event.String(), q.Schedule(ctx, query, "list", event.String(),

View File

@ -111,9 +111,7 @@ class MailHandler:
'''SELECT '''SELECT
"id", "id",
"owner_id", "owner_id",
"nonsubscriber_permissions", "default_access",
"subscriber_permissions",
"account_permissions",
"permit_mimetypes", "permit_mimetypes",
"reject_mimetypes" "reject_mimetypes"
FROM "list" FROM "list"
@ -260,12 +258,9 @@ class MailHandler:
send_error_for.delay(mail_b64, unknown_mailing_list_error.format( send_error_for.delay(mail_b64, unknown_mailing_list_error.format(
sender, posting_domain, posting_domain)) sender, posting_domain, posting_domain))
return "250 Mailing list not found, but sending bounce out of band" return "250 Mailing list not found, but sending bounce out of band"
(dest_id, owner_id, (dest_id, owner_id, default_access,
nonsub_perms, sub_perms, external_perms,
permit_mimetypes, reject_mimetypes) = dest permit_mimetypes, reject_mimetypes) = dest
nonsub_perms = ListAccess(nonsub_perms) default_access = ListAccess(default_access)
sub_perms = ListAccess(sub_perms)
external_perms = ListAccess(external_perms)
fetch_email = await self.fetch_email(conn) fetch_email = await self.fetch_email(conn)
in_reply_to = mail.get("In-Reply-To") in_reply_to = mail.get("In-Reply-To")
@ -308,24 +303,11 @@ class MailHandler:
print("Rejected: your account is not allowed to post to this list") print("Rejected: your account is not allowed to post to this list")
return "500 Rejected. Your account is not allowed to post to this list." return "500 Rejected. Your account is not allowed to post to this list."
else: else:
fetch_sub = await self.fetch_subscription(conn) if access not in default_access:
sub = await fetch_sub.fetchval(_from[1], user_id)
if access not in nonsub_perms and not sub:
user_errors_processed.inc() user_errors_processed.inc()
print("Rejected: non-subscribers are not allowed to post") print("Rejected: default ACL does not allow posting")
return "500 Rejected. Non-subscribers are not allowed to post to this list."
if access not in sub_perms and sub:
user_errors_processed.inc()
print("Rejected: non-subscribers are not allowed to post")
return "500 Rejected. You are not allowed to post to this list." return "500 Rejected. You are not allowed to post to this list."
if access not in external_perms and not user_id:
user_errors_processed.inc()
print("Rejected: non-users are not allowed to post")
return "500 Rejected. Users without an account are not allowed to post to this list."
forwards_processed.inc() forwards_processed.inc()
print("Message accepted: {}".format(mail.get("Subject"))) print("Message accepted: {}".format(mail.get("Subject")))
dispatch_message.delay(address, dest_id, mail_b64) dispatch_message.delay(address, dest_id, mail_b64)

View File

@ -0,0 +1,30 @@
"""Consolidate list permissions columns
Revision ID: 12d3ae26f3a3
Revises: 6deff4db061c
Create Date: 2022-05-30 09:39:30.629372
"""
# revision identifiers, used by Alembic.
revision = '12d3ae26f3a3'
down_revision = '6deff4db061c'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.execute("""
ALTER TABLE list RENAME COLUMN nonsubscriber_permissions TO default_access;
ALTER TABLE list DROP COLUMN subscriber_permissions;
ALTER TABLE list DROP COLUMN account_permissions;
""")
def downgrade():
op.execute("""
ALTER TABLE list RENAME COLUMN default_access TO nonsubscriber_permissions;
ALTER TABLE list ADD COLUMN subscriber_permissions integer NOT NULL DEFAULT 7;
ALTER TABLE list ADD COLUMN account_permissions integer NOT NULL DEFAULT 7;
""")

View File

@ -0,0 +1,43 @@
"""Add list visibility column
Revision ID: c09158fb4d2d
Revises: 12d3ae26f3a3
Create Date: 2022-05-30 10:44:19.524347
"""
# revision identifiers, used by Alembic.
revision = 'c09158fb4d2d'
down_revision = '12d3ae26f3a3'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.execute("""
CREATE TYPE visibility AS ENUM (
'PUBLIC', 'UNLISTED', 'PRIVATE'
);
ALTER TABLE list
ADD COLUMN visibility visibility;
UPDATE list
SET visibility =
CASE WHEN default_access & 1 > 0
THEN 'PUBLIC'::visibility
ELSE 'PRIVATE'::visibility
END;
ALTER TABLE list
ALTER COLUMN visibility
SET NOT NULL;
""")
def downgrade():
op.execute("""
ALTER TABLE list DROP COLUMN visibility;
DROP TYPE visibility;
""")

View File

@ -1,6 +1,6 @@
import pkg_resources import pkg_resources
from flask import abort from flask import abort
from listssrht.blueprints.archives import get_list as _get_list from listssrht.blueprints.archives import get_list as _get_list, get_access
from listssrht.types import Email, User, Subscription, ListAccess from listssrht.types import Email, User, Subscription, ListAccess
from listssrht.webhooks import UserWebhook from listssrht.webhooks import UserWebhook
from srht.flask import csrf_bypass from srht.flask import csrf_bypass
@ -26,15 +26,6 @@ def get_list(owner_name, list_name):
abort(404) abort(404)
return owner, ml, access return owner, ml, access
def get_access(ml, user):
if user.id == ml.owner_id:
access = ListAccess.all
elif Subscription.query .filter(Subscription.user_id == user.id).count():
access = ml.subscriber_permissions | ml.account_permissions
else:
access = ml.account_permissions
return access
def get_email(email_id): def get_email(email_id):
"""Fetches an email by email ID or message ID""" """Fetches an email by email ID or message ID"""
try: try:

View File

@ -14,9 +14,8 @@ def user_emails_GET(username):
user = get_user(username) user = get_user(username)
emails = Email.query.filter(Email.sender_id == user.id) emails = Email.query.filter(Email.sender_id == user.id)
if current_token.user_id != user.id: if current_token.user_id != user.id:
emails = emails.join(List, List.id == Email.list_id).filter(or_( emails = emails.join(List, List.id == Email.list_id).filter(
List.account_permissions > 0, List.default_access > 0)
List.nonsubscriber_permissions > 0))
return paginated_response(Email.id, return paginated_response(Email.id,
emails.order_by(Email.created.desc()), short=True) emails.order_by(Email.created.desc()), short=True)

View File

@ -1,7 +1,7 @@
from flask import current_app, Blueprint, abort, request from flask import current_app, Blueprint, abort, request
from listssrht.blueprints.api import get_user, get_list from listssrht.blueprints.api import get_user, get_list
from listssrht.blueprints.archives import apply_search from listssrht.blueprints.archives import apply_search
from listssrht.types import List, Email, ListAccess, Subscription from listssrht.types import List, Email, ListAccess, Subscription, Visibility
from listssrht.webhooks import ListWebhook, UserWebhook from listssrht.webhooks import ListWebhook, UserWebhook
from sqlalchemy import or_ from sqlalchemy import or_
from srht.api import paginated_response from srht.api import paginated_response
@ -19,9 +19,7 @@ def user_lists_GET(username):
user = get_user(username) user = get_user(username)
lists = List.query.filter(List.owner_id == user.id) lists = List.query.filter(List.owner_id == user.id)
if current_token.user_id != user.id: if current_token.user_id != user.id:
lists = lists.filter(or_( lists = lists.filter(List.visibility == Visibility.PUBLIC)
List.account_permissions > 0,
List.nonsubscriber_permissions > 0))
return paginated_response(List.id, lists) return paginated_response(List.id, lists)
@lists.route("/api/lists", methods=["POST"]) @lists.route("/api/lists", methods=["POST"])
@ -36,7 +34,7 @@ def user_lists_POST():
resp = exec_gql(current_app.site, """ resp = exec_gql(current_app.site, """
mutation CreateMailingList($name: String!, $description: String) { mutation CreateMailingList($name: String!, $description: String) {
createMailingList(name: $name, description: $description) { createMailingList(name: $name, description: $description, visibility: PUBLIC) {
id id
name name
owner { owner {
@ -48,17 +46,7 @@ def user_lists_POST():
created created
updated updated
description description
nonsubscriber { defaultACL {
browse
reply
post
}
subscriber {
browse
reply
post
}
identified {
browse browse
reply reply
post post
@ -77,13 +65,11 @@ def user_lists_POST():
] if acl[key]] ] if acl[key]]
resp["permissions"] = { resp["permissions"] = {
"nonsubscriber": permList(resp["nonsubscriber"]), "nonsubscriber": permList(resp["defaultACL"]),
"subscriber": permList(resp["subscriber"]), "subscriber": permList(resp["defaultACL"]),
"account": permList(resp["identified"]), "account": permList(resp["defaultACL"]),
} }
del resp["nonsubscriber"] del resp["defaultACL"]
del resp["subscriber"]
del resp["identified"]
return resp, 201 return resp, 201
@ -142,17 +128,7 @@ def user_lists_by_name_PUT(list_name):
created created
updated updated
description description
nonsubscriber { defaultACL {
browse
reply
post
}
subscriber {
browse
reply
post
}
identified {
browse browse
reply reply
post post
@ -171,13 +147,11 @@ def user_lists_by_name_PUT(list_name):
] if acl[key]] ] if acl[key]]
resp["permissions"] = { resp["permissions"] = {
"nonsubscriber": permList(resp["nonsubscriber"]), "nonsubscriber": permList(resp["defaultACL"]),
"subscriber": permList(resp["subscriber"]), "subscriber": permList(resp["defaultACL"]),
"account": permList(resp["identified"]), "account": permList(resp["defaultACL"]),
} }
del resp["nonsubscriber"] del resp["defaultACL"]
del resp["subscriber"]
del resp["identified"]
return resp return resp

View File

@ -10,7 +10,7 @@ from srht.flask import paginate_query
from srht.oauth import current_user, loginrequired from srht.oauth import current_user, loginrequired
from srht.validation import Validation from srht.validation import Validation
from listssrht.filters import post_address from listssrht.filters import post_address
from listssrht.types import List, User, Email, Subscription, ListAccess, Access from listssrht.types import List, User, Email, Subscription, ListAccess, Access, Visibility
from listssrht.types import Patchset, PatchsetStatus from listssrht.types import Patchset, PatchsetStatus
from listssrht.process import forward_thread from listssrht.process import forward_thread
from listssrht.webhooks import ListWebhook, UserWebhook from listssrht.webhooks import ListWebhook, UserWebhook
@ -38,28 +38,37 @@ def get_list(owner_name, list_name, current_user=current_user):
ml = (List.query ml = (List.query
.filter(List.name.ilike(list_name.replace('_', '\\_'))) .filter(List.name.ilike(list_name.replace('_', '\\_')))
.filter(List.owner_id == owner.id) .filter(List.owner_id == owner.id)
.one_or_none()
) )
if current_user:
ml = ml.outerjoin(Access, Access.list_id == List.id)
ml = ml.one_or_none()
if not ml: if not ml:
return None, None, None return None, None, None
if current_user: access = get_access(ml, user=current_user)
acl = next((acl for acl in ml.acls if access == ListAccess.none and ml.visibility == Visibility.PRIVATE:
if acl.user_id == current_user.id), None) abort(401)
if current_user.id == ml.owner_id:
access = ListAccess.all
elif acl:
access = acl.permissions
elif (Subscription.query
.filter(Subscription.user_id == current_user.id)).count():
access = ml.subscriber_permissions | ml.account_permissions
else:
access = ml.account_permissions
else:
access = ml.nonsubscriber_permissions
return owner, ml, access return owner, ml, access
def get_access(ml, user=None):
user = user or current_user
# Anonymous
if not user:
if ml.visibility == Visibility.PRIVATE:
return ListAccess.none
return ml.default_access
# Owner
if user.id == ml.owner_id:
return ListAccess.all
# ACL entry?
user_access = Access.query.filter_by(list=ml, user=user).first()
if user_access:
return user_access.permissions
if ml.visibility == Visibility.PRIVATE:
return ListAccess.none
return ml.default_access
def apply_search(query, search): def apply_search(query, search):
if not search: if not search:
return query.filter(Email.parent_id == None) return query.filter(Email.parent_id == None)
@ -173,8 +182,6 @@ def archive(owner_name, list_name):
owner, ml, access = get_list(owner_name, list_name) owner, ml, access = get_list(owner_name, list_name)
if not ml: if not ml:
abort(404) abort(404)
if access.value == 0:
abort(403)
threads = (Email.query threads = (Email.query
.filter(Email.list_id == ml.id) .filter(Email.list_id == ml.id)
).order_by(Email.updated.desc()) ).order_by(Email.updated.desc())

View File

@ -51,7 +51,7 @@ def info_POST(owner_name, list_name):
rewrite = lambda value: None if value == "" else value rewrite = lambda value: None if value == "" else value
input = { input = {
key: rewrite(valid.source[key]) for key in [ key: rewrite(valid.source[key]) for key in [
"description" "description", "visibility"
] if valid.source.get(key) is not None ] if valid.source.get(key) is not None
} }
@ -180,7 +180,7 @@ def acl_POST(owner_name, list_name):
else: else:
acl.email = username acl.email = username
acl.permissions = _process_access(valid, "acl") acl.permissions = _process_access(valid, "acl")
if ListAccess.browse in ml.nonsubscriber_permissions: if ListAccess.browse in ml.default_access:
acl.permissions |= ListAccess.browse acl.permissions |= ListAccess.browse
db.session.add(acl) db.session.add(acl)
db.session.commit() db.session.commit()

View File

@ -10,7 +10,7 @@ from srht.graphql import exec_gql
from srht.search import search_by from srht.search import search_by
from srht.validation import Validation from srht.validation import Validation
from sqlalchemy import or_ from sqlalchemy import or_
from listssrht.types import List, ListAccess, User, Email, Subscription, Mirror from listssrht.types import List, ListAccess, User, Email, Subscription, Mirror, Visibility
from listssrht.webhooks import UserWebhook from listssrht.webhooks import UserWebhook
import re import re
import smtplib import smtplib
@ -49,19 +49,9 @@ def user_profile(username):
recent = Email.query.filter(Email.sender_id == user.id) recent = Email.query.filter(Email.sender_id == user.id)
lists = List.query.filter(List.owner_id == user.id) lists = List.query.filter(List.owner_id == user.id)
if current_user: if not current_user or current_user.id != user.id:
if current_user.id != user.id: lists = lists.filter(List.visibility == Visibility.PUBLIC)
lists = lists.filter(or_( recent = recent.join(List).filter(List.visibility == Visibility.PUBLIC)
List.account_permissions.op('&')(ListAccess.browse) > 0,
List.nonsubscriber_permissions.op('&')(ListAccess.browse) > 0))
recent = recent.join(List).filter(or_(
List.account_permissions.op('&')(ListAccess.browse) > 0,
List.nonsubscriber_permissions.op('&')(ListAccess.browse) > 0))
else:
lists = (lists
.filter(List.nonsubscriber_permissions.op('&')(ListAccess.browse) > 0))
recent = (recent.join(List)
.filter(List.nonsubscriber_permissions.op('&')(ListAccess.browse) > 0))
recent = recent.order_by(Email.created.desc()).limit(10).all() recent = recent.order_by(Email.created.desc()).limit(10).all()
lists = lists.order_by(List.updated.desc()).limit(10).all() lists = lists.order_by(List.updated.desc()).limit(10).all()
@ -76,14 +66,8 @@ def lists_for_user(username):
abort(404) abort(404)
lists = List.query.filter(List.owner_id == user.id) lists = List.query.filter(List.owner_id == user.id)
if current_user: if not current_user or current_user.id != user.id:
if current_user.id != user.id: lists = lists.filter(List.visibility == Visibility.PUBLIC)
lists = lists.filter(or_(
List.account_permissions.op('&')(ListAccess.browse) > 0,
List.nonsubscriber_permissions.op('&')(ListAccess.browse) > 0
))
else:
lists = lists.filter(List.nonsubscriber_permissions.op('&')(ListAccess.browse) > 0)
lists = lists.order_by(List.updated.desc()) lists = lists.order_by(List.updated.desc())
terms = request.args.get('search') terms = request.args.get('search')
@ -111,19 +95,20 @@ def create_list_POST():
valid = Validation(request) valid = Validation(request)
name = valid.require("name", friendly_name="Name") name = valid.require("name", friendly_name="Name")
description = valid.optional("description") description = valid.optional("description")
visibility = valid.require("visibility")
if not valid.ok: if not valid.ok:
return render_template("create.html", **valid.kwargs) return render_template("create.html", **valid.kwargs)
resp = exec_gql(current_app.site, """ resp = exec_gql(current_app.site, """
mutation CreateMailingList($name: String!, $description: String) { mutation CreateMailingList($name: String!, $description: String, $visibility: Visibility!) {
createMailingList(name: $name, description: $description) { createMailingList(name: $name, description: $description, visibility: $visibility) {
name name
owner { owner {
canonicalName canonicalName
} }
} }
} }
""", valid=valid, name=name, description=description) """, valid=valid, name=name, description=description, visibility=visibility)
if not valid.ok: if not valid.ok:
return render_template("create.html", **valid.kwargs) return render_template("create.html", **valid.kwargs)

View File

@ -185,12 +185,8 @@ def _update_patchset_status(dest, sender, patchset, status):
access = ListAccess.all access = ListAccess.all
elif acl: elif acl:
access = acl.permissions access = acl.permissions
elif isinstance(sender, User) and (Subscription.query
.filter(Subscription.user_id == sender.id)).count():
access = dest.subscriber_permissions | dest.account_permissions
else: else:
access = (dest.account_permissions access = dest.default_access
if isinstance(sender, User) else dest.nonsubscriber_permissions)
if ListAccess.moderate not in access: if ListAccess.moderate not in access:
print("Patchset update requested, but user has insufficient permissions") print("Patchset update requested, but user has insufficient permissions")
@ -346,7 +342,7 @@ def _subscribe(dest, mail):
sender = parseaddr(mail["From"]) sender = parseaddr(mail["From"])
user = User.query.filter(User.email == sender[1]).one_or_none() user = User.query.filter(User.email == sender[1]).one_or_none()
if user: if user:
perms = dest.account_permissions perms = dest.default_access
sub = Subscription.query.filter( sub = Subscription.query.filter(
Subscription.list_id == dest.id, Subscription.list_id == dest.id,
Subscription.user_id == user.id).one_or_none() Subscription.user_id == user.id).one_or_none()
@ -356,7 +352,7 @@ def _subscribe(dest, mail):
if access: if access:
perms = access.permissions perms = access.permissions
else: else:
perms = dest.nonsubscriber_permissions perms = dest.default_access
sub = Subscription.query.filter( sub = Subscription.query.filter(
Subscription.list_id == dest.id, Subscription.list_id == dest.id,
Subscription.email == sender[1]).one_or_none() Subscription.email == sender[1]).one_or_none()

View File

@ -1,40 +1,88 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
<div class="container"> <div class="container">
<div class="row"> <form class="row" method="POST" action="{{url_for(".create_list_POST")}}">
<div class="col-md-6"> <div class="col-md-12">
<h3>Create new mailing list</h3> <h3>Create new mailing list</h3>
<form method="POST" action="{{url_for(".create_list_POST")}}">
{{csrf_token()}}
<div class="form-group">
<label for="name">Name</label>
<input
type="text"
name="name"
id="name"
class="form-control {{valid.cls("name")}}"
value="{{ name or "" }}" />
{{valid.summary("name")}}
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
name="description"
id="description"
class="form-control {{valid.cls("description")}}"
value="{{ description or "" }}"
placeholder="Markdown supported"
rows="5"
aria-describedby="description-help"
>{{description or ""}}</textarea>
{{valid.summary("description")}}
</div>
{{valid.summary()}}
<button type="submit" class="btn btn-primary">
Create {{icon("caret-right")}}
</button>
</form>
</div> </div>
</div> <div class="col-md-6">
{{csrf_token()}}
<div class="form-group">
<label for="name">Name</label>
<input
type="text"
name="name"
id="name"
class="form-control {{valid.cls("name")}}"
value="{{ name or "" }}" />
{{valid.summary("name")}}
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
name="description"
id="description"
class="form-control {{valid.cls("description")}}"
value="{{ description or "" }}"
placeholder="Markdown supported"
rows="5"
aria-describedby="description-help"
>{{description or ""}}</textarea>
{{valid.summary("description")}}
</div>
{{valid.summary()}}
<button type="submit" class="btn btn-primary">
Create {{icon("caret-right")}}
</button>
</div>
<div class="col-md-6 d-flex flex-column">
<fieldset class="form-group">
<legend>Mailing List Visibility</legend>
<div class="form-check">
<label class="form-check-label">
<input
class="form-check-input"
type="radio"
name="visibility"
value="PUBLIC"
checked> Public
<small id="visibility-public-help" class="form-text text-muted">
Shown on your profile page
</small>
</label>
</div>
<div class="form-check">
<label
class="form-check-label"
title="Visible to anyone with the link, but not shown on your profile"
>
<input
class="form-check-input"
type="radio"
name="visibility"
value="UNLISTED"> Unlisted
<small id="visibility-unlisted-help" class="form-text text-muted">
Visible to anyone who knows the URL, but not shown on your profile
</small>
</label>
</div>
<div class="form-check">
<label
class="form-check-label"
title="Only visible to you and your collaborators"
>
<input
class="form-check-input"
type="radio"
name="visibility"
value="PRIVATE"> Private
<small id="visibility-unlisted-help" class="form-text text-muted">
Only visible to you and your collaborators
</small>
</label>
</div>
</fieldset>
</div>
</form>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -24,6 +24,22 @@
href="{{ path }}">{{ title }}</a> href="{{ path }}">{{ title }}</a>
{% endmacro %} {% endmacro %}
<ul class="nav nav-tabs"> <ul class="nav nav-tabs">
{% if ml.visibility.value != "PUBLIC" %}
<li
class="nav-item nav-text vis-{{ml.visibility.value.lower()}}"
{% if ml.visibility.value == "UNLISTED" %}
title="This tracker is only visible to those who know the URL."
{% elif ml.visibility.value == "PRIVATE" %}
title="This tracker is only visible to those who were invited to view it."
{% endif %}
>
{% if ml.visibility.value == "UNLISTED" %}
Unlisted
{% elif ml.visibility.value == "PRIVATE" %}
Private
{% endif %}
</li>
{% endif %}
<li class="nav-item"> <li class="nav-item">
{{link(url_for("archives.archive", {{link(url_for("archives.archive",
owner_name=owner.canonical_name, owner_name=owner.canonical_name,

View File

@ -61,7 +61,7 @@
specific access configuration. specific access configuration.
</p> </p>
{% for a in access_type_list %} {% for a in access_type_list %}
{{ perm_checkbox(a, ml.nonsubscriber_permissions , "default") }} {{ perm_checkbox(a, ml.default_access, "default") }}
{% endfor %} {% endfor %}
{{ valid.summary("list_default_access") }} {{ valid.summary("list_default_access") }}
</div> </div>

View File

@ -6,48 +6,105 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container"> <div class="container">
<div class="row"> <form class="row" method="POST">
<div class="col-md-8"> <div class="col-md-6">
<form method="POST"> {{csrf_token()}}
{{csrf_token()}} <div class="form-group">
<div class="form-group"> <label for="name">
<label for="name"> Name
Name <span class="text-muted">(you can't edit this)</p>
<span class="text-muted">(you can't edit this)</p> </label>
<input
type="text"
name="name"
id="name"
class="form-control"
value="{{ ml.name }}"
disabled />
</div>
<div class="form-group {{valid.cls("description")}}">
<label for="description">Description</label>
<textarea
name="description"
id="description"
class="form-control"
placeholder="Markdown supported"
rows="10"
aria-describedby="description-help"
>{{description or ml.description or ""}}</textarea>
{{ valid.summary("description") }}
</div>
{{ valid.summary() }}
<span class="pull-right">
<a
href="{{ url_for("archives.archive",
owner_name=ml.owner.canonical_name,
list_name=ml.name) }}"
class="btn btn-default"
>Cancel</a>
<button type="submit" class="btn btn-primary">
Save {{icon("caret-right")}}
</button>
</span>
</div>
<div class="col-md-6 d-flex flex-column">
<fieldset class="form-group">
<legend>Mailing List Visibility</legend>
<div class="form-check">
<label class="form-check-label">
<input
class="form-check-input"
type="radio"
name="visibility"
value="PUBLIC"
{% if ml.visibility.value == "PUBLIC" %}
checked
{% endif %}
> Public
<small id="visibility-public-help" class="form-text text-muted">
Shown on your profile page
</small>
</label> </label>
<input
type="text"
name="name"
id="name"
class="form-control"
value="{{ ml.name }}"
disabled />
</div> </div>
<div class="form-group {{valid.cls("description")}}"> <div class="form-check">
<label for="description">Description</label> <label
<textarea class="form-check-label"
name="description" title="Visible to anyone with the link, but not shown on your profile"
id="description" >
class="form-control" <input
placeholder="Markdown supported" class="form-check-input"
rows="10" type="radio"
aria-describedby="description-help" name="visibility"
>{{description or ml.description or ""}}</textarea> value="UNLISTED"
{{ valid.summary("description") }} {% if ml.visibility.value == "UNLISTED" %}
checked
{% endif %}
> Unlisted
<small id="visibility-unlisted-help" class="form-text text-muted">
Visible to anyone who knows the URL, but not shown on your profile
</small>
</label>
</div> </div>
{{ valid.summary() }} <div class="form-check">
<span class="pull-right"> <label
<a class="form-check-label"
href="{{ url_for("archives.archive", title="Only visible to you and your collaborators"
owner_name=ml.owner.canonical_name, >
list_name=ml.name) }}" <input
class="btn btn-default" class="form-check-input"
>Cancel</a> type="radio"
<button type="submit" class="btn btn-primary"> name="visibility"
Save {{icon("caret-right")}} value="PRIVATE"
</button> {% if ml.visibility.value == "PRIVATE" %}
</span> checked
</form> {% endif %}
> Private
<small id="visibility-unlisted-help" class="form-text text-muted">
Only visible to you and your collaborators
</small>
</label>
</div>
</fieldset>
</div> </div>
</div> </div>
</div> </div>

View File

@ -39,6 +39,11 @@
list_name=list.name list_name=list.name
) }}" ) }}"
>{{list.owner.canonical_name}}/{{list.name}}</a> >{{list.owner.canonical_name}}/{{list.name}}</a>
{% if list.visibility.value != 'PUBLIC' %}
<small class="pull-right">
{{ list.visibility.value.lower() }}
</small>
{% endif %}
</h4> </h4>
{% if list.description %} {% if list.description %}
{{list.description|md}} {{list.description|md}}

View File

@ -1,7 +1,7 @@
from listssrht.types.listaccess import ListAccess from listssrht.types.listaccess import ListAccess
from listssrht.types.access import Access from listssrht.types.access import Access
from listssrht.types.email import Email from listssrht.types.email import Email
from listssrht.types.list import List from listssrht.types.list import List, Visibility
from listssrht.types.patchset import Patchset, PatchsetStatus from listssrht.types.patchset import Patchset, PatchsetStatus
from listssrht.types.patchset import PatchsetTool, ToolIcon from listssrht.types.patchset import PatchsetTool, ToolIcon
from listssrht.types.subscription import Subscription from listssrht.types.subscription import Subscription

View File

@ -1,9 +1,16 @@
import re import re
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy_utils as sau
from enum import Enum
from srht.flagtype import FlagType from srht.flagtype import FlagType
from srht.database import Base from srht.database import Base
from listssrht.types.listaccess import ListAccess from listssrht.types.listaccess import ListAccess
class Visibility(Enum):
PUBLIC = 'PUBLIC'
UNLISTED = 'UNLISTED'
PRIVATE = 'PRIVATE'
class List(Base): class List(Base):
__tablename__ = 'list' __tablename__ = 'list'
__table_args__ = sa.UniqueConstraint('owner_id', 'name', __table_args__ = sa.UniqueConstraint('owner_id', 'name',
@ -14,26 +21,12 @@ class List(Base):
updated = sa.Column(sa.DateTime, nullable=False) updated = sa.Column(sa.DateTime, nullable=False)
name = sa.Column(sa.String(128), nullable=False) name = sa.Column(sa.String(128), nullable=False)
description = sa.Column(sa.Unicode(2048)) description = sa.Column(sa.Unicode(2048))
visibility = sa.Column(sau.ChoiceType(Visibility), nullable=False)
import_in_progress = sa.Column( import_in_progress = sa.Column(
sa.Boolean, nullable=False, server_default='f') sa.Boolean, nullable=False, server_default='f')
nonsubscriber_permissions = sa.Column(FlagType(ListAccess), default_access = sa.Column(FlagType(ListAccess),
nullable=False, server_default=str(ListAccess.normal.value)) nullable=False, server_default=str(ListAccess.normal.value))
"""
Permissions granted to users who are not subscribed or logged in.
"""
subscriber_permissions = sa.Column(FlagType(ListAccess),
nullable=False, server_default=str(ListAccess.normal.value))
"""
Permissions granted to users who are subscribed to the list.
"""
account_permissions = sa.Column(FlagType(ListAccess),
nullable=False, server_default=str(ListAccess.normal.value))
"""
Permissions granted to holders of sr.ht accounts.
"""
permit_mimetypes = sa.Column(sa.Unicode, nullable=False, permit_mimetypes = sa.Column(sa.Unicode, nullable=False,
server_default="text/*,application/pgp-signature,application/pgp-keys") server_default="text/*,application/pgp-signature,application/pgp-keys")
@ -89,9 +82,9 @@ class List(Base):
"updated": self.updated, "updated": self.updated,
"description": self.description, "description": self.description,
"permissions": { "permissions": {
"nonsubscriber": permissions(self.nonsubscriber_permissions), "nonsubscriber": permissions(self.default_access),
"subscriber": permissions(self.subscriber_permissions), "subscriber": permissions(self.default_access),
"account": permissions(self.account_permissions), "account": permissions(self.default_access),
}, },
} if not short else {}) } if not short else {})
} }