1
0
Fork 0
mirror of https://github.com/Eggbertx/gochan.git synced 2025-09-04 10:06:24 -07:00

Add more Postgres db migration for IP -> INET, split dbtypes into their own files

This commit is contained in:
Eggbertx 2024-01-09 13:11:22 -08:00
parent d294462968
commit 0e34698257
8 changed files with 386 additions and 198 deletions

View file

@ -1,6 +1,8 @@
package common
import (
"database/sql"
"errors"
"fmt"
"os"
"regexp"
@ -14,6 +16,42 @@ var (
commentRemover = regexp.MustCompile("--.*\n?")
)
// ColumnType returns a string representation of the column's data type. It does not return an error
// if the column does not exist, instead returning an empty string.
func ColumnType(db *gcsql.GCDB, tx *sql.Tx, columnName string, tableName string, dbName string, dbType string) (string, error) {
var query string
var dataType string
var err error
var params []any
switch dbType {
case "mysql":
query = `SELECT DATA_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1`
params = []any{dbName, tableName, columnName}
case "postgresql":
query = `SELECT data_type FROM information_schema.columns
WHERE (table_schema = ? OR table_schema = 'public')
AND table_name = ? AND column_name = ? LIMIT 1`
params = []any{dbName, tableName, columnName}
case "sqlite3":
query = `SELECT type FROM pragma_table_info(?) WHERE name = ?`
params = []any{tableName, columnName}
default:
return "", gcsql.ErrUnsupportedDB
}
err = db.QueryRowTxSQL(tx, query, params, []any{&dataType})
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return dataType, err
}
// IsStringType returns true if the given column data type is TEXT or VARCHAR
func IsStringType(dataType string) bool {
lower := strings.ToLower(dataType)
return strings.HasPrefix(lower, "varchar") || lower == "text"
}
func RunSQLFile(path string, db *gcsql.GCDB) error {
sqlBytes, err := os.ReadFile(path)
if err != nil {

View file

@ -55,13 +55,9 @@ func (dbu *GCDatabaseUpdater) MigrateDB() (bool, error) {
return migrated, err
}
var query string
var tableName string
var numConstraints int
var numColumns int
var rangeStart string
var rangeEnd string
criticalConfig := config.GetSystemCriticalConfig()
dbName := criticalConfig.DBname
dbType := criticalConfig.DBtype
ctx := context.Background()
tx, err := dbu.db.BeginTx(ctx, &sql.TxOptions{
Isolation: 0,
@ -70,204 +66,21 @@ func (dbu *GCDatabaseUpdater) MigrateDB() (bool, error) {
if err != nil {
return false, err
}
defer func() {
tx.Rollback()
}()
defer tx.Rollback()
switch criticalConfig.DBtype {
case "mysql":
query = `SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_NAME = 'wordfilters_board_id_fk'
AND TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'DBPREFIXwordfilters'`
if err = dbu.db.QueryRowTxSQL(tx, query, nil, []any{&numConstraints}); err != nil {
return false, err
}
if numConstraints > 0 {
query = `ALTER TABLE DBPREFIXwordfilters DROP FOREIGN KEY wordfilters_board_id_fk`
} else {
query = ""
}
query = `SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'DBPREFIXwordfilters'
AND COLUMN_NAME = 'board_dirs'`
if err = dbu.db.QueryRowTxSQL(tx, query, nil, []any{&numColumns}); err != nil {
return false, err
}
if numColumns == 0 {
query = `ALTER TABLE DBPREFIXwordfilters ADD COLUMN board_dirs varchar(255) DEFAULT '*'`
if _, err = dbu.db.ExecTxSQL(tx, query); err != nil {
return false, err
}
}
// Yay, collation! Everybody loves MySQL's default collation!
query = `ALTER DATABASE ` + criticalConfig.DBname + ` CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci`
if _, err = tx.Exec(query); err != nil {
return false, err
}
query = `SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?`
rows, err := dbu.db.QuerySQL(query, criticalConfig.DBname)
if err != nil {
return false, err
}
defer func() {
rows.Close()
}()
for rows.Next() {
err = rows.Scan(&tableName)
if err != nil {
return false, err
}
query = `ALTER TABLE ` + tableName + ` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
if _, err = tx.Exec(query); err != nil {
return false, err
}
}
if err = rows.Close(); err != nil {
return false, err
}
query = `SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'DBPREFIXip_ban'
AND COLUMN_NAME = 'ip'`
if err = dbu.db.QueryRowTxSQL(tx, query, nil, []any{&numColumns}); err != nil {
return false, err
}
if numColumns > 0 {
// add range_start and range_end columns
query = `ALTER TABLE DBPREFIXip_ban
ADD COLUMN IF NOT EXISTS range_start VARBINARY(16) NOT NULL,
ADD COLUMN IF NOT EXISTS range_end VARBINARY(16) NOT NULL`
if _, err = dbu.db.ExecTxSQL(tx, query); err != nil {
return false, err
}
// convert ban IP string to IP range
if rows, err = dbu.db.QuerySQL(`SELECT id, ip FROM DBPREFIXip_ban`); err != nil {
return false, err
}
for rows.Next() {
var id int
var ipOrCIDR string
if err = rows.Scan(&id, &ipOrCIDR); err != nil {
return false, err
}
if rangeStart, rangeEnd, err = gcutil.ParseIPRange(ipOrCIDR); err != nil {
return false, err
}
query = `UPDATE DBPREFIXip_ban
SET range_start = INET6_ATON(?), range_end = INET6_ATON(?) WHERE id = ?`
if _, err = dbu.db.ExecTxSQL(tx, query, rangeStart, rangeEnd, id); err != nil {
return false, err
}
query = `ALTER TABLE DBPREFIXip_ban DROP COLUMN IF EXISTS ip`
if _, err = dbu.db.ExecTxSQL(tx, query); err != nil {
return false, err
}
}
if err = rows.Close(); err != nil {
return false, err
}
}
// Convert DBPREFIXposts.ip to from varchar to varbinary
query = `SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'DBPREFIXposts'
AND COLUMN_NAME = 'ip'
AND DATA_TYPE = 'varchar'`
if err = dbu.db.QueryRowTxSQL(tx, query, nil, []any{&numColumns}); err != nil {
return false, err
}
if numColumns == 1 {
// rename `ip` to a temporary column to then be removed
query = `ALTER TABLE DBPREFIXposts CHANGE ip ip_str varchar(45)`
if _, err = dbu.db.ExecTxSQL(tx, query); err != nil {
return false, err
}
query = `ALTER TABLE DBPREFIXposts
ADD COLUMN IF NOT EXISTS ip VARBINARY(16) NOT NULL`
if _, err = dbu.db.ExecTxSQL(tx, query); err != nil {
return false, err
}
// convert post IP VARCHAR(45) to VARBINARY(16)
query = `UPDATE DBPREFIXposts SET ip = INET6_ATON(ip_str)`
if _, err = dbu.db.ExecTxSQL(tx, query); err != nil {
return false, err
}
query = `ALTER TABLE DBPREFIXposts DROP COLUMN IF EXISTS ip_str`
if _, err = dbu.db.ExecTxSQL(tx, query); err != nil {
return false, err
}
}
// Convert DBPREFIXreports.ip to from varchar to varbinary
query = `SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'DBPREFIXreports'
AND COLUMN_NAME = 'ip'
AND DATA_TYPE = 'varchar'`
if err = dbu.db.QueryRowTxSQL(tx, query, nil, []any{&numColumns}); err != nil {
return false, err
}
if numColumns == 1 {
// rename `ip` to a temporary column to then be removed
query = `ALTER TABLE DBPREFIXreports CHANGE ip ip_str varchar(45)`
if _, err = dbu.db.ExecTxSQL(tx, query); err != nil {
return false, err
}
query = `ALTER TABLE DBPREFIXreports
ADD COLUMN IF NOT EXISTS ip VARBINARY(16) NOT NULL`
if _, err = dbu.db.ExecTxSQL(tx, query); err != nil {
return false, err
}
// convert post IP VARCHAR(45) to VARBINARY(16)
query = `UPDATE DBPREFIXreports SET ip = INET6_ATON(ip_str)`
if _, err = dbu.db.ExecTxSQL(tx, query); err != nil {
return false, err
}
query = `ALTER TABLE DBPREFIXreports DROP COLUMN IF EXISTS ip_str`
if _, err = dbu.db.ExecTxSQL(tx, query); err != nil {
return false, err
}
}
err = nil
err = updateMysqlDB(dbu.db, tx, dbName, dbType)
case "postgres":
_, err = gcsql.ExecSQL(`ALTER TABLE DBPREFIXwordfilters DROP CONSTRAINT IF EXISTS board_id_fk`)
if err != nil {
return false, err
}
query = `ALTER TABLE DBPREFIXwordfilters ADD COLUMN IF NOT EXISTS board_dirs varchar(255) DEFAULT '*'`
if _, err = dbu.db.ExecTxSQL(tx, query); err != nil {
return false, err
}
err = updatePostgresDB(dbu.db, tx, dbName, dbType)
case "sqlite3":
_, err = dbu.db.ExecSQL(`PRAGMA foreign_keys = ON`)
if err != nil {
return false, err
}
query = `SELECT COUNT(*) FROM PRAGMA_TABLE_INFO('DBPREFIXwordfilters') WHERE name = 'board_dirs'`
var numColumns int
if err = dbu.db.QueryRowSQL(query, nil, []any{&numColumns}); err != nil {
return false, err
}
if numColumns == 0 {
query = `ALTER TABLE DBPREFIXwordfilters ADD COLUMN board_dirs varchar(255) DEFAULT '*'`
if _, err = dbu.db.ExecTxSQL(tx, query); err != nil {
return false, err
}
}
err = updateSqliteDB(dbu.db, tx, dbName, dbType)
}
if err != nil {
return false, err
}
query = `UPDATE DBPREFIXdatabase_version SET version = ? WHERE component = 'gochan'`
query := `UPDATE DBPREFIXdatabase_version SET version = ? WHERE component = 'gochan'`
_, err = dbu.db.ExecTxSQL(tx, query, latestDatabaseVersion)
if err != nil {
return false, err

View file

@ -0,0 +1,166 @@
package gcupdate
import (
"database/sql"
"github.com/gochan-org/gochan/cmd/gochan-migration/internal/common"
"github.com/gochan-org/gochan/pkg/gcsql"
"github.com/gochan-org/gochan/pkg/gcutil"
)
func updateMysqlDB(db *gcsql.GCDB, tx *sql.Tx, dbName string, dbType string) error {
var numConstraints int
var err error
query := `SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_NAME = 'wordfilters_board_id_fk'
AND TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'DBPREFIXwordfilters'`
if err = db.QueryRowTxSQL(tx, query, nil, []any{&numConstraints}); err != nil {
return err
}
if numConstraints > 0 {
query = `ALTER TABLE DBPREFIXwordfilters DROP FOREIGN KEY wordfilters_board_id_fk`
} else {
query = ""
}
dataType, err := common.ColumnType(db, tx, "board_dirs", "DBPREFIXwordfilters", dbName, dbType)
if err != nil {
return err
}
if dataType == "" {
query = `ALTER TABLE DBPREFIXwordfilters ADD COLUMN board_dirs varchar(255) DEFAULT '*'`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
}
// Yay, collation! Everybody loves MySQL's default collation!
query = `ALTER DATABASE ` + dbName + ` CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci`
if _, err = tx.Exec(query); err != nil {
return err
}
query = `SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?`
rows, err := db.QuerySQL(query, dbName)
if err != nil {
return err
}
defer func() {
rows.Close()
}()
var tableName string
for rows.Next() {
err = rows.Scan(&tableName)
if err != nil {
return err
}
query = `ALTER TABLE ` + tableName + ` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
if _, err = tx.Exec(query); err != nil {
return err
}
}
if err = rows.Close(); err != nil {
return err
}
dataType, err = common.ColumnType(db, tx, "ip", "DBPREFIXip_ban", dbName, dbType)
if err != nil {
return err
}
if dataType == "" {
// add range_start and range_end columns
query = `ALTER TABLE DBPREFIXip_ban
ADD COLUMN IF NOT EXISTS range_start VARBINARY(16) NOT NULL,
ADD COLUMN IF NOT EXISTS range_end VARBINARY(16) NOT NULL`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
// convert ban IP string to IP range
if rows, err = db.QuerySQL(`SELECT id, ip FROM DBPREFIXip_ban`); err != nil {
return err
}
var rangeStart, rangeEnd string
for rows.Next() {
var id int
var ipOrCIDR string
if err = rows.Scan(&id, &ipOrCIDR); err != nil {
return err
}
if rangeStart, rangeEnd, err = gcutil.ParseIPRange(ipOrCIDR); err != nil {
return err
}
query = `UPDATE DBPREFIXip_ban
SET range_start = INET6_ATON(?), range_end = INET6_ATON(?) WHERE id = ?`
if _, err = db.ExecTxSQL(tx, query, rangeStart, rangeEnd, id); err != nil {
return err
}
query = `ALTER TABLE DBPREFIXip_ban DROP COLUMN IF EXISTS ip`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
}
if err = rows.Close(); err != nil {
return err
}
}
// Convert DBPREFIXposts.ip to from varchar to varbinary
dataType, err = common.ColumnType(db, tx, "ip", "DBPREFIXposts", dbName, dbType)
if err != nil {
return err
}
if common.IsStringType(dataType) {
// rename `ip` to a temporary column to then be removed
query = `ALTER TABLE DBPREFIXposts CHANGE ip ip_str varchar(45)`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
query = `ALTER TABLE DBPREFIXposts
ADD COLUMN IF NOT EXISTS ip VARBINARY(16) NOT NULL`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
// convert post IP VARCHAR(45) to VARBINARY(16)
query = `UPDATE DBPREFIXposts SET ip = INET6_ATON(ip_str)`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
query = `ALTER TABLE DBPREFIXposts DROP COLUMN IF EXISTS ip_str`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
}
// Convert DBPREFIXreports.ip to from varchar to varbinary
dataType, err = common.ColumnType(db, tx, "ip", "DBPREFIXreports", dbName, dbType)
if err != nil {
return err
}
if common.IsStringType(dataType) {
// rename `ip` to a temporary column to then be removed
query = `ALTER TABLE DBPREFIXreports CHANGE ip ip_str varchar(45)`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
query = `ALTER TABLE DBPREFIXreports
ADD COLUMN IF NOT EXISTS ip VARBINARY(16) NOT NULL`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
// convert report IP VARCHAR(45) to VARBINARY(16)
query = `UPDATE DBPREFIXreports SET ip = INET6_ATON(ip_str)`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
query = `ALTER TABLE DBPREFIXreports DROP COLUMN IF EXISTS ip_str`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,71 @@
package gcupdate
import (
"database/sql"
"github.com/gochan-org/gochan/cmd/gochan-migration/internal/common"
"github.com/gochan-org/gochan/pkg/gcsql"
)
func updatePostgresDB(db *gcsql.GCDB, tx *sql.Tx, dbName string, dbType string) error {
query := `ALTER TABLE DBPREFIXwordfilters
DROP CONSTRAINT IF EXISTS board_id_fk`
_, err := db.ExecSQL(query)
if err != nil {
return err
}
query = `ALTER TABLE DBPREFIXwordfilters
ADD COLUMN IF NOT EXISTS board_dirs varchar(255) DEFAULT '*'`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
dataType, err := common.ColumnType(db, tx, "ip", "DBPREFIXposts", dbName, dbType)
if err != nil {
return err
}
if common.IsStringType(dataType) {
// change ip column to temporary ip_str
query = `ALTER TABLE DBPREFIXposts RENAME COLUMN ip TO ip_str,`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
// add ip column with INET type, default '127.0.0.1' because it throws an error otherwise
// because it is non-nil
query = `ALTER TABLE DBPREFIXposts
ADD COLUMN IF NOT EXISTS ip INET NOT NULL DEFAULT '127.0.0.1'`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
query = `UPDATE TABLE DBPREFIXposts SET ip = ip_str`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
query = `ALTER TABLE DBPREFIXposts DROP COLUMN ip_str`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
}
dataType, err = common.ColumnType(db, tx, "ip", "DBPREFIXip_ban", dbName, dbType)
if err != nil {
return err
}
if dataType != "" {
query = `ALTER TABLE DBPREFIXip_ban
ADD COLUMN IF NOT EXISTS range_start INET NOT NULL,
ADD COLUMN IF NOT EXISTS range_end INET NOT NULL`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
query = `UPDATE DBPREFIXip_ban SET range_start = ip::INET, SET range_end = ip::INET`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,26 @@
package gcupdate
import (
"database/sql"
"github.com/gochan-org/gochan/cmd/gochan-migration/internal/common"
"github.com/gochan-org/gochan/pkg/gcsql"
)
func updateSqliteDB(db *gcsql.GCDB, tx *sql.Tx, dbName string, dbType string) error {
_, err := db.ExecSQL(`PRAGMA foreign_keys = ON`)
if err != nil {
return err
}
dataType, err := common.ColumnType(db, tx, "DBPREFIXwordfilters", "board_dirs", dbName, dbType)
if err != nil {
return err
}
if dataType == "" {
query := `ALTER TABLE DBPREFIXwordfilters ADD COLUMN board_dirs varchar(255) DEFAULT '*'`
if _, err = db.ExecTxSQL(tx, query); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Shell script that downloads a previous gochan release for testing gochan-migration -updatedb
# This should only be used in a development environment
TESTING_VERSION="v3.7.0"
RELEASE_DIR="gochan-${TESTING_VERSION}_linux"
RELEASE_GZ="$RELEASE_DIR.tar.gz"
RELEASE_URL="https://github.com/gochan-org/gochan/releases/download/$TESTING_VERSION/$RELEASE_GZ"
if [ "$USER" != "vagrant" ]; then
echo "This must be run in the vagrant VM (expected \$USER to be vagrant, got $USER)"
exit 1
fi
cd ~
rm -f $RELEASE_GZ
echo "Downloading $RELEASE_GZ"
wget -q --show-progress $RELEASE_URL
echo "Extracting $RELEASE_GZ"
tar -xf gochan-${TESTING_VERSION}_linux.tar.gz
cd $RELEASE_DIR
cp examples/configs/gochan.example.json gochan.json
echo "Modifying $PWD/gochan.json for testing migration"
sed -i gochan.json \
-e 's/"Port": .*/"Port": 9000,/' \
-e 's/"UseFastCGI": false/"UseFastCGI": true/' \
-e "s/\"DBtype\": .*/\"DBtype\": \""$DBTYPE"\",/" \
-e 's/"DBpassword": ""/"DBpassword": "gochan"/' \
-e 's/"DBname": "gochan"/"DBname": "gochan_37"/' \
-e 's/"SiteName": "Gochan"/"SiteName": "Gochan Migration Test"/' \
-e 's/"SiteSlogan": ""/"SiteSlogan": "Gochan instance used for testing gochan-migrate -updatedb"/' \
-e 's/"DebugMode": false/"DebugMode": true/' \
-e 's/"Verbosity": 0/"Verbosity": 1/'
if [ "$DBTYPE" = "mysql" ]; then
echo "Creating testing MySQL DB 'gochan_37' if it doesn't already exist"
sudo mysql <<- EOF1
CREATE DATABASE IF NOT EXISTS gochan_37;
GRANT USAGE ON *.* TO gochan IDENTIFIED BY 'gochan'; \
GRANT ALL PRIVILEGES ON gochan_37.* TO gochan; \
SET PASSWORD FOR 'gochan'@'%' = PASSWORD('gochan');
FLUSH PRIVILEGES;
EOF1
elif [ "$DBTYPE" = "postgresql" ]; then
echo "Creating testing PostgreSQL DB 'gochan_37' if it doesn't already exist"
sed -i /etc/gochan/gochan.json \
-e 's/"DBhost": ".*"/"DBhost": "127.0.0.1"/'
sudo -u postgres psql -f - <<- EOF1
CREATE DATABASE gochan_37;
GRANT ALL PRIVILEGES ON DATABASE gochan_37 TO gochan;
EOF1
else
echo "Currently using unsupported \$DBTYPE: $DBTYPE"
exit 1
fi
sudo ./gochan

View file

@ -123,6 +123,21 @@ type IPBan struct {
ipBanBase
}
// IP was previously a field in the IPBan struct before range bans were
// implemented. This is here as a fallback for templates
//
// Deprecated: Use the RangeStart and RangeEnd fields or gcutil.GetIPRangeSubnet
func (ipb *IPBan) IP() string {
if ipb.RangeStart == ipb.RangeEnd {
return ipb.RangeStart
}
inet, err := gcutil.GetIPRangeSubnet(ipb.RangeStart, ipb.RangeEnd)
if err != nil {
return "?"
}
return inet.String()
}
func (ipb *IPBan) IsBanned(ipStr string) (bool, error) {
ipn, err := gcutil.GetIPRangeSubnet(ipb.RangeStart, ipb.RangeEnd)
if err != nil {

View file

@ -2,7 +2,7 @@
<input type="hidden" name="do" value="add" />
<h2>Add IP ban</h2>
<table>
<tr><th>Mask</th><td><input type="text" name="ip" value="{{banMask .ban}}" style="width: 100%;"/></td></tr>
<tr><th>IP/Mask</th><td><input type="text" name="ip" value="{{banMask .ban}}" style="width: 100%;"/></td></tr>
<tr><th>Duration</th><td><input type="text" name="duration" style="width: 100%;" {{if gt .ban.ID 0}}value="{{until .ban.ExpiresAt}}"{{end}}/></td></tr>
<tr><th></th><td>e.g. '1y2mo3w4d5h6m7s',<br />'1 year 2 months 3 weeks 4 days 5 hours 6 minutes 7 seconds'<br/>Optional if "Permanent" is checked, required otherwise</td></tr>
<tr><th>Permanent</th><td><input type="checkbox" name="permanent" id="permanent" {{if .ban.Permanent}}checked{{end}}> (overrides the duration)</td></tr>