Documentation
¶
Overview ¶
Package admin is a model-driven CRUD panel: register a struct, get a working list and edit screen.
Django's admin is still the canonical reason people pick Django, and Go has had nothing maintained -- the largest project in the space had not been pushed to in fourteen months when this was written. This is the useful 90% of it: reflection over the structs an application already has, rendered server-side with the query builder and the standard library.
Three lines ¶
panel := admin.New(admin.Config{DB: db, Driver: "sqlite", Authorizer: myRules})
panel.Register(admin.Resource{Model: User{}, Table: "users"})
mux.Mount("/admin", panel.Handler("/admin"))
That is a list with search, filters, sorting and pagination, and a form with an input per column chosen from the field's type.
It refuses to work until you say who may use it ¶
The zero value of Config has no authorizer, and a panel without one answers every request with 404. There is no permissive default, because the failure mode of one is a CRUD interface to the entire database published to the internet by someone who was going to configure it later.
404 rather than 403, and for an unauthenticated visitor rather than 403 with a login prompt: an admin panel that confirms its own existence has told a scanner exactly where to point the credential stuffing.
What it deliberately is not ¶
Filament is a framework inside a framework -- form builders, widgets, charts, a plugin ecosystem -- and that is both why it has three million installs and why it is somebody's full-time job. This is model-driven CRUD plus a slot for custom pages, and it stops there. The custom-page slot is half the value: the admin is where internal tooling ends up living.
No build step ¶
Server-rendered HTML with inline CSS, and the only JavaScript is a handful of lines for the select-all checkbox. No npm, no bundler, no CDN -- which also means no CSP exception and nothing to keep up to date.
Rendering is html/template rather than the framework's Jet. Jet escapes, but html/template escapes *contextually* -- differently inside an attribute, a URL and a script -- and this package renders arbitrary database content into all three. That is the whole argument; it is also one fewer dependency.
Index ¶
- Constants
- Variables
- func DefaultPermissions() map[Action]auth.Permission
- type Action
- type Audit
- func (a *Audit) For(ctx context.Context, resource, recordID string, limit int) ([]Entry, error)
- func (a *Audit) Migrate(ctx context.Context) error
- func (a *Audit) Prune(ctx context.Context) (int64, error)
- func (a *Audit) Record(ctx context.Context, e Entry) error
- func (a *Audit) WithTable(name string) *Audit
- type Authorizer
- type AuthorizerFunc
- type BelongsTo
- type BulkAction
- type Choice
- type Config
- type Content
- type Context
- type Entry
- type Field
- type FieldKind
- type FileStore
- type HasMany
- type Organization
- type Page
- type Panel
- type Query
- type Resource
- type Uploader
- type UploaderFunc
Constants ¶
const ( // DefaultPerPage is a page of rows. DefaultPerPage = 25 // MaxPerPage bounds what a URL may ask for. Without it, ?per_page=1000000 // is a denial of service one query string long. MaxPerPage = 200 // MaxListColumns keeps a wide table readable, and keeps the row's own // actions on screen rather than off the right-hand edge. MaxListColumns = 6 )
const DefaultHasManyLimit = 25
DefaultHasManyLimit bounds an inline list, because "show every related row" is how an admin page for a popular record becomes a database incident.
const MaxUploadBytes = 32 << 20 // 32 MiB
MaxUploadBytes bounds a single file. Without a cap, an upload field is a way to fill a disk from a browser.
Variables ¶
var ( ErrUnauthenticated = errors.New("admin: not signed in") ErrForbidden = errors.New("admin: not permitted") )
The two ways to refuse, and the difference between them is what a visitor already knows.
ErrUnauthenticated becomes 404: nobody has identified themselves, so the answer must not confirm that this path is an admin panel, that a resource by that name exists, or that the record they guessed at is real. An admin panel that announces itself has told a scanner where to point the credential stuffing.
ErrForbidden becomes 403: this is a known account being told no. Hiding the panel from someone already looking at it buys nothing and produces support tickets about pages that "disappeared".
Functions ¶
func DefaultPermissions ¶
func DefaultPermissions() map[Action]auth.Permission
DefaultPermissions is the mapping most applications want: reading needs read, everything that writes needs write.
Types ¶
type Audit ¶
type Audit struct {
// Retain bounds how long entries are kept. Zero keeps them forever, which
// is a decision rather than a default: an audit trail that a cleanup job
// truncates is not one.
Retain time.Duration
// contains filtered or unexported fields
}
Audit records who changed what.
The question an admin panel eventually produces is "who deleted that", and it can only be answered if the answer was written down before it was asked. One row per write, with the changed columns and nothing else -- storing whole records would turn the trail into a second copy of every table, including the columns this package refuses to display.
func NewAudit ¶
NewAudit returns an audit trail on the default table.
driver is the DATABASE_TYPE value, because the DDL below differs between MySQL and SQLite in ways the placeholder dialect does not distinguish -- both use ? and only one of them spells it AUTOINCREMENT.
type Authorizer ¶
Authorizer decides who may do what.
There is no default that allows anything. A panel without an authorizer refuses every request, because the failure mode of the other default is a CRUD interface to the whole database published to the internet.
var AllowAll Authorizer = AuthorizerFunc(func(Context, Query) error { return nil })
AllowAll permits everything.
For local development and for tests. Naming it this loudly is deliberate: it should be obvious in a diff, and it should be obvious in a review that a production configuration containing it is a finding.
var DenyAll Authorizer = AuthorizerFunc(func(Context, Query) error { return ErrUnauthenticated })
DenyAll refuses everything, invisibly. It is the zero-configuration default.
func RoleAuthorizer ¶
func RoleAuthorizer(store auth.OrganizationStore, perms auth.Permissions, who Organization, required map[Action]auth.Permission) Authorizer
RoleAuthorizer permits actions by the account's role in its organization, using the auth package's membership lookup.
The lookup happens on every request rather than being read from the session. A session carrying the role keeps granting it after the person has been removed, until they happen to log out -- which is the difference between revocation and a suggestion.
required maps an action to the permission it needs. Actions missing from the map are refused, so adding an action to this package cannot silently widen what an existing deployment allows.
type AuthorizerFunc ¶
AuthorizerFunc adapts a function.
type BelongsTo ¶
type BelongsTo struct {
// Table is the table the key points at.
Table string
// Key is the column in that table, usually its primary key.
Key string
// Label is the column to show a person. An id in a dropdown is a puzzle.
Label string
}
BelongsTo describes a foreign key.
type BulkAction ¶
type BulkAction struct {
// Name appears in the URL and must be unique within the resource.
Name string
// Label is the button.
Label string
// Confirm asks first. Use it for anything that destroys data.
Confirm bool
// Run receives the selected primary keys.
//
// Authorization has already run: the panel checks ActionUpdate on every
// selected record before calling this, so a bulk action cannot be a way
// around per-record permissions.
Run func(ctx Context, ids []string) error
}
BulkAction is something a person can do to several selected rows at once.
type Config ¶
type Config struct {
// DB is the database the resources live in.
DB *sql.DB
// Driver is the application's DATABASE_TYPE: "sqlite", "mysql",
// "mariadb", "postgres". It selects both the placeholder syntax and the
// audit table's schema.
Driver string
// Authorizer decides who may do what. Without one nothing is permitted.
Authorizer Authorizer
// Title is shown in the header. Defaults to "Admin".
Title string
// Actor names the person behind a request, for the audit trail. Optional;
// without it the trail records the action and not who took it, which is
// half a trail.
Actor func(ctx Context) string
// Audit records who changed what. Nil disables it.
//
// Enabled by default when a DB is present: an admin panel is where someone
// eventually asks "who deleted that", and the answer has to have been
// recorded before the question.
Audit *Audit
// Uploads receives files from file fields. Without it, a file field falls
// back to a plain text input for a path.
Uploads Uploader
}
Config configures a panel.
type Content ¶
Content is HTML a custom page produced. It is inserted without escaping, so a page that renders user input must escape it -- html/template is right there.
type Context ¶
type Context struct {
context.Context
// Request is the HTTP request behind this, for a session lookup or a
// header. Never nil.
Request *http.Request
}
Context is what an authorizer and a bulk action are given.
type Entry ¶
type Entry struct {
At time.Time
Actor string
Action Action
Resource string
RecordID string
// Changes holds only what differed, as column -> [before, after]. A create
// records the values it set; a delete records the record as it was.
Changes map[string]any
}
Entry is one recorded change.
type Field ¶
type Field struct {
// Name is the database column.
Name string
// Label is what a person sees. Derived from the column unless overridden.
Label string
Kind FieldKind
// PrimaryKey marks the identifying column. Never editable.
PrimaryKey bool
// ReadOnly shows the value but does not accept a new one.
//
// Enforced when the form is processed, not only when it is drawn. A
// disabled input is a suggestion to the browser and nothing at all to
// anything else.
ReadOnly bool
// Hidden keeps the column out of every screen.
Hidden bool
// Required rejects an empty value.
Required bool
// Searchable includes the column in the search box's LIKE.
Searchable bool
// Relation is set for a belongs-to column: the values offered are the
// related table's rows.
Relation *BelongsTo
// Choices turns a column into a fixed dropdown.
Choices []Choice
}
Field is one column of a resource.
type FieldKind ¶
type FieldKind string
FieldKind is how a column is rendered and parsed.
const ( KindText FieldKind = "text" KindLongText FieldKind = "longtext" KindNumber FieldKind = "number" KindDecimal FieldKind = "decimal" KindBool FieldKind = "bool" KindTime FieldKind = "time" KindEmail FieldKind = "email" KindURL FieldKind = "url" KindSelect FieldKind = "select" KindFile FieldKind = "file" )
type FileStore ¶
type FileStore struct {
// FS is the destination.
FS filesystems.FS
// Folder is the prefix within it.
Folder string
// MaxBytes overrides MaxUploadBytes.
MaxBytes int64
// AllowedExtensions restricts what may be uploaded, lower case and with
// the dot: {".png", ".jpg"}. Empty allows anything, which is a decision to
// make deliberately -- an admin panel that accepts .html is a way to host
// an attacker's page on your origin, and one that accepts .svg is the same
// thing with a friendlier extension.
AllowedExtensions []string
}
FileStore stores uploads through the framework's filesystems package, so a panel gets S3 or MinIO by being handed the filesystem the application already configured.
type HasMany ¶
type HasMany struct {
// Title is the heading above the list.
Title string
// Table holds the related rows.
Table string
// ForeignKey is the column in Table pointing at this record.
ForeignKey string
// Columns are shown, in order. Empty means the first three the table has.
Columns []string
// Limit caps how many are listed. Zero means DefaultHasManyLimit.
Limit int
}
HasMany describes rows in another table that point back at this one.
type Organization ¶
Organization is how a request's account and organization are found.
The panel cannot know where an application keeps them -- session, JWT, mTLS certificate -- so it asks. Return ("", "", ErrForbidden) for an unauthenticated request.
type Page ¶
type Page struct {
// Path is the URL segment, e.g. "reports".
Path string
// Title is the nav entry and heading.
Title string
// Body renders the page's content, which the panel wraps in its chrome.
//
// Returning HTML rather than writing to the ResponseWriter is what makes
// the page look like the rest of the panel without every page having to
// reproduce the layout.
Body func(ctx Context) (Content, error)
// Handler takes the whole request instead, for a page that streams, or
// redirects, or is not HTML. Set one or the other; Handler wins.
Handler http.Handler
// Post handles a form submitted from this page and returns where to send
// the browser next. An empty redirect returns to the page itself.
//
// A page without one accepts no writes at all: an internal tool that only
// reads should not have a POST endpoint just because pages can have them.
Post func(ctx Context) (redirect string, err error)
// Action is the permission checked before the page is shown. Defaults to
// ActionList.
Action Action
// PostAction is the permission checked before Post runs. Defaults to
// ActionUpdate, so a page that reads with one role and writes with another
// gets that without configuration.
PostAction Action
}
Page is a custom screen: the half of an admin panel that is not CRUD.
type Panel ¶
type Panel struct {
// contains filtered or unexported fields
}
Panel is a mounted admin.
func (*Panel) Audit ¶
Audit returns the trail, so an application can migrate it, prune it, or read it from a page of its own.
func (*Panel) Handler ¶
Handler returns the panel's routes, to be mounted at mount.
mount is needed because every link the panel writes is absolute: a panel that guessed its own prefix from the request would break the moment it was mounted behind a path-rewriting proxy, and guessing wrong means every link is broken rather than one.
type Query ¶
Query is one authorization question.
Record is nil for a list or a create -- there is no row yet. Field is set only when the question is about one column, which is what makes per-field permissions possible without a second interface.
type Resource ¶
type Resource struct {
// Model is any value of the struct type. Its fields are reflected over;
// it is never stored or mutated.
Model any
// Table is the database table.
Table string
// Name is the URL segment and heading. Derived from Table when empty.
Name string
// Singular and Plural override the headings.
Singular string
Plural string
// PrimaryKey defaults to "id".
PrimaryKey string
// ListColumns limits the list view. Empty shows every visible field, up to
// MaxListColumns -- a table with forty columns is not a useful screen.
ListColumns []string
// DefaultSort is the column the list is ordered by. Defaults to the
// primary key, descending.
DefaultSort string
DefaultDesc bool
// PerPage defaults to DefaultPerPage.
PerPage int
// HasMany renders related rows on the edit screen.
HasMany []HasMany
// BulkActions appear on the list when rows are selected.
BulkActions []BulkAction
// FieldOverrides adjusts what reflection worked out. The key is the column.
FieldOverrides map[string]Field
// ReadOnly forbids create, update and delete for everyone, regardless of
// what the authorizer says. For tables that are reports.
ReadOnly bool
// contains filtered or unexported fields
}
Resource is a table exposed in the panel.
The minimum is a model and a table:
panel.Register(admin.Resource{Model: User{}, Table: "users"})
Everything else -- columns, labels, input types, which fields are searchable -- is read off the struct.
func (*Resource) EditableFields ¶
EditableFields are the ones a form may write.
func (*Resource) ListFields ¶
ListFields are the columns of the list view.
func (*Resource) SearchFields ¶
SearchFields are the text columns the search box matches against.
func (*Resource) VisibleFields ¶
VisibleFields are the ones a screen may show.
type Uploader ¶
type Uploader interface {
Upload(ctx Context, field string, file multipart.File, header *multipart.FileHeader) (string, error)
}
Uploader stores a file from a file field and returns the value to write into the column.
What that value means is the application's business -- a key, a path, a URL. This package writes back whatever it is given.
type UploaderFunc ¶
type UploaderFunc func(ctx Context, field string, file multipart.File, header *multipart.FileHeader) (string, error)
UploaderFunc adapts a function.