Heavy commit of qrz
All checks were successful
continuous-integration/drone/push Build is passing

- SQL injection protection with regex and prepared statements
- Handled new website
- Refactored ws
- CSV export handle
- modified HTML, CSS and JS
This commit is contained in:
Paul 2020-05-23 12:32:36 +02:00
parent be699390b6
commit 4239c1dbb1
11 changed files with 290 additions and 145 deletions

View File

@ -23,12 +23,13 @@ db_name="database"
db_username="username"
db_password="password"
db_table="qrz_test"
cron="@every 1h"
```
### Run
```bash
./qrz -configfile qrz.ini
./qrz -configfile qrz.ini -port 8080
```
## License

View File

@ -11,9 +11,12 @@ import (
_ "github.com/go-sql-driver/mysql"
)
var version string
func main() {
var config config.Config
config.GetConfig()
config.Version = version
err := database.Initialize(&config)
defer config.Db.Close()

View File

@ -4,5 +4,4 @@ db_name="database"
db_username="username"
db_password="password"
db_table="qrz_test"
port=8080
cron="@every 1h"

View File

@ -12,10 +12,14 @@ import (
// GetConfig fetch configuration
func (config *Config) GetConfig() error {
var configfile string
var nofeed bool
var port int
flag.Usage = utils.Usage
flag.StringVar(&configfile, "configfile", "qrz.ini", "config file to use with qrz section")
flag.IntVar(&port, "port", 8080, "web port to use")
flag.BoolVar(&nofeed, "nofeed", false, "no feed database table with entries at first launch")
flag.Parse()
cfg, err := ini.Load(configfile)
@ -25,7 +29,8 @@ func (config *Config) GetConfig() error {
qrzsection := cfg.Section("qrz")
config.Port = qrzsection.Key("port").MustInt(8080)
config.Port = port
config.NoFeed = nofeed
config.DbHostname = qrzsection.Key("db_hostname").MustString("localhost")
config.DbName = qrzsection.Key("db_name").MustString("database")
config.DbUsername = qrzsection.Key("db_username").MustString("username")
@ -34,37 +39,69 @@ func (config *Config) GetConfig() error {
config.DbTable = qrzsection.Key("db_table").MustString("qrz")
config.Cron = qrzsection.Key("cron").MustString("@every 1h")
config.DbSchema = fmt.Sprintf("CREATE TABLE IF NOT EXISTS `%s` (`id` int(8) NOT NULL AUTO_INCREMENT, `qrz` varchar(25) NOT NULL, `name` varchar(25) DEFAULT NULL, `address` varchar(50) DEFAULT NULL, `city` varchar(50) DEFAULT NULL, `zipcode` varchar(5) DEFAULT NULL, `dept` varchar(50) DEFAULT NULL, `country` varchar(25) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `qrz` (`qrz`,`name`,`city`,`dept`) USING BTREE, KEY `test` (`country`), FULLTEXT KEY `city` (`city`), FULLTEXT KEY `dept` (`dept`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;", config.DbTable)
config.DbSchema = fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS %s
(
id int(8) NOT NULL AUTO_INCREMENT,
qrz varchar(25) NOT NULL,
name varchar(25) DEFAULT NULL,
address varchar(50) DEFAULT NULL,
city varchar(50) DEFAULT NULL,
zipcode varchar(5) DEFAULT NULL,
dept varchar(50) DEFAULT NULL,
country varchar(25) DEFAULT NULL,
dmrid varchar(25) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY qrz (qrz,name,city,dept) USING BTREE,
KEY test (country),
FULLTEXT KEY city (city),
FULLTEXT KEY dept (dept)
)
ENGINE=InnoDB DEFAULT CHARSET=utf8;`,
config.DbTable)
config.Statements.DbInsert = "INSERT IGNORE INTO %s (qrz,name,city,dept,country) VALUES ('%s','%s','%s','%s','%s');"
config.DbStatements.Insert = fmt.Sprintf(
`INSERT IGNORE INTO %s (qrz, dmrid, name, city, dept, country)
VALUES (?,?,?,?,?,?);`,
config.DbTable)
config.DbStatements.ExportCSV = fmt.Sprintf(
`SELECT qrz, name, city, dept, country
FROM %s;`,
config.DbTable)
config.Statements.DbGetQrz = "SELECT qrz FROM %s;"
config.DbStatements.Countries = fmt.Sprintf(
`SELECT country
FROM %s
GROUP BY country;`,
config.DbTable)
config.Statements.DbCheck = "SELECT COUNT(*) FROM %s WHERE qrz = '%s';"
config.URLBase = "http://groupe-frs.hamstation.eu/index_qrz_liste_%s.htm"
config.URLBase = `http://groupe-frs.hamstation.eu/index_qrz_liste_%s.php`
config.QrzGroups = []string{"01", "03", "09", "103", "104", "107", "119", "13", "14", "146", "147", "15", "156", "16", "161", "163", "18", "188", "2", "214", "233", "25", "26", "29", "30", "31", "32", "34", "43", "44", "49", "54", "64", "66", "76", "79", "84", "97", "98"}
//config.QrzGroups = map[string]string{"France": "14"}
return nil
}
// Config is the global config of g2g
// Config is the global config of qrz
type Config struct {
Port int
DbHostname string
DbName string
DbUsername string
DbPassword string
DbSchema string
DbTable string
Statements struct {
DbInsert string
DbGetQrz string
DbCheck string
Db *sqlx.DB
DbHostname string
DbName string
DbUsername string
DbPassword string
DbSchema string
DbTable string
DbStatements struct {
Insert string
ExportCSV string
Countries string
}
URLBase string
QrzGroups []string
Cron string
Db *sqlx.DB
Port int
NoFeed bool
Version string
}

View File

@ -1,6 +1,7 @@
package qrz
import (
"database/sql"
"fmt"
"io/ioutil"
"log"
@ -11,7 +12,6 @@ import (
"git.paulbsd.com/paulbsd/qrz/src/config"
"github.com/antchfx/htmlquery"
"github.com/robfig/cron"
"golang.org/x/text/encoding/charmap"
)
// InitCronConfig create task schedules
@ -74,18 +74,19 @@ func getBody(url string) (string, error) {
return "", err
}
defer resp.Body.Close()
dec := charmap.ISO8859_1.NewDecoder()
output, _ := dec.Bytes(body)
bodyString := string(output)
return bodyString, nil
/*
// For old website support 23/05/2020
dec := charmap.ISO8859_1.NewDecoder()
output, _ := dec.Bytes(body)
bodyString := string(output)
*/
return string(body), nil
}
// getFrsEntries get FRS entries from html body
func getFrsEntries(config config.Config, body string) (frsentries map[string]FrsEntry, err error) {
frsentries = make(map[string]FrsEntry)
re := regexp.MustCompile(`^[0-9]{1,4}\s[A-Z]{1,4}\s[0-9]{1,4}`)
re := regexp.MustCompile(`^ [0-9]{1,4}\s[A-Z]{1,4}\s[0-9]{1,4}`)
htmlpage, err := htmlquery.Parse(strings.NewReader(body))
if err != nil {
@ -94,14 +95,25 @@ func getFrsEntries(config config.Config, body string) (frsentries map[string]Frs
for _, n := range htmlquery.Find(htmlpage, "//tr") {
td := htmlquery.Find(n, "//td")
if re.MatchString(htmlquery.InnerText(td[0])) {
frs := FrsEntry{
QRZ: strings.Replace(htmlquery.InnerText(td[0]), "'", "\\'", -1),
Name: strings.Replace(htmlquery.InnerText(td[1]), "'", "\\'", -1),
City: strings.Replace(htmlquery.InnerText(td[2]), "'", "\\'", -1),
Dept: strings.Replace(htmlquery.InnerText(td[3]), "'", "\\'", -1),
Country: strings.Replace(htmlquery.InnerText(td[4]), "'", "\\'", -1)}
frsentries[frs.QRZ] = frs
if len(td) > 2 {
if re.MatchString(htmlquery.InnerText(td[1])) {
frs := FrsEntry{
QRZ: strings.TrimLeft(htmlquery.InnerText(td[1]), " "),
DMRID: sql.NullString{String: strings.TrimLeft(htmlquery.InnerText(td[0]), " "), Valid: true},
Name: sql.NullString{String: strings.TrimLeft(htmlquery.InnerText(td[2]), " "), Valid: true},
City: sql.NullString{String: strings.TrimLeft(htmlquery.InnerText(td[3]), " "), Valid: true},
Dept: sql.NullString{String: strings.TrimLeft(htmlquery.InnerText(td[4]), " "), Valid: true},
Country: sql.NullString{String: strings.TrimLeft(htmlquery.InnerText(td[5]), " "), Valid: true}}
frsentries[frs.QRZ] = frs
} else if re.MatchString(htmlquery.InnerText(td[0])) {
frs := FrsEntry{
QRZ: strings.TrimLeft(htmlquery.InnerText(td[0]), " "),
Name: sql.NullString{String: strings.TrimLeft(htmlquery.InnerText(td[1]), " "), Valid: true},
City: sql.NullString{String: strings.TrimLeft(htmlquery.InnerText(td[2]), " "), Valid: true},
Dept: sql.NullString{String: strings.TrimLeft(htmlquery.InnerText(td[3]), " "), Valid: true},
Country: sql.NullString{String: strings.TrimLeft(htmlquery.InnerText(td[4]), " "), Valid: true}}
frsentries[frs.QRZ] = frs
}
}
}
@ -109,24 +121,16 @@ func getFrsEntries(config config.Config, body string) (frsentries map[string]Frs
}
// getCurrentEntries fetch existing entries from database
func getCurrentEntries(config config.Config) (existingQRZ []string, err error) {
q := fmt.Sprintf(config.Statements.DbGetQrz, config.DbTable)
rows, err := config.Db.Query(q)
defer rows.Close()
for rows.Next() {
var i string
rows.Scan(&i)
existingQRZ = append(existingQRZ, i)
}
func getCurrentEntries(config config.Config) (existingQRZ []FrsEntry, err error) {
err = config.Db.Select(&existingQRZ, fmt.Sprintf("SELECT * FROM %s;", config.DbTable))
return
}
// discardExistingEntries remove existing entries from original map[string]FrsEntry
func discardExistingEntries(config config.Config, frsPeople *map[string]FrsEntry, existingQRZ []string) (err error) {
func discardExistingEntries(config config.Config, frsPeople *map[string]FrsEntry, existingQRZ []FrsEntry) (err error) {
for _, entry := range existingQRZ {
delete(*frsPeople, entry)
delete(*frsPeople, entry.QRZ)
}
return
@ -139,9 +143,8 @@ func insertFrsEntryToDB(config config.Config, frsPeople map[string]FrsEntry) (er
fmt.Println(fmt.Sprintf("Starting inserts of %d entries", len(frsPeople)))
for _, j := range frsPeople {
query := fmt.Sprintf(config.Statements.DbInsert, config.DbTable, j.QRZ, j.Name, j.City, j.Dept, j.Country)
tx.MustExec(query)
for _, frs := range frsPeople {
tx.MustExec(config.DbStatements.Insert, frs.QRZ, frs.DMRID, frs.Name, frs.City, frs.Dept, frs.Country)
qrzNum++
}
@ -156,9 +159,13 @@ func insertFrsEntryToDB(config config.Config, frsPeople map[string]FrsEntry) (er
// FrsEntry describe FRS people
type FrsEntry struct {
QRZ string `db:"qrz"`
Name string `db:"name"`
City string `db:"city"`
Dept string `db:"dept"`
Country string `db:"country"`
ID int `db:"id"`
QRZ string `db:"qrz"`
DMRID sql.NullString `db:"dmrid"`
Name sql.NullString `db:"name"`
Address sql.NullString `db:"address"`
City sql.NullString `db:"city"`
ZipCode sql.NullString `db:"zipcode"`
Dept sql.NullString `db:"dept"`
Country sql.NullString `db:"country"`
}

View File

@ -4,6 +4,7 @@ import (
"fmt"
"log"
"net/http"
"regexp"
"strings"
"git.paulbsd.com/paulbsd/qrz/src/config"
@ -12,6 +13,7 @@ import (
"git.paulbsd.com/paulbsd/qrz/src/static"
"git.paulbsd.com/paulbsd/qrz/src/templates"
"github.com/gobuffalo/packr/v2"
"github.com/jmoiron/sqlx"
"github.com/labstack/echo/v4"
)
@ -35,12 +37,21 @@ func RunServer(config config.Config) (err error) {
e.POST("/qrzws", func(c echo.Context) (err error) {
res, err := Run(c, config)
if err != nil {
log.Fatalln(err)
return c.String(http.StatusInternalServerError, "Erreur 500 ta mère")
}
return c.JSON(http.StatusOK, res)
})
e.GET("/export_frs.csv", func(c echo.Context) (err error) {
data, mime, err := RunCSVExport(c, config)
if err != nil {
log.Fatalln(err)
}
return c.Blob(http.StatusOK, mime, data)
})
go qrz.Run(config)
if !config.NoFeed {
go qrz.Run(config)
}
e.Logger.Fatal(e.Start(fmt.Sprintf(":%d", config.Port)))
return
}
@ -49,15 +60,16 @@ func RunServer(config config.Config) (err error) {
func Run(c echo.Context, config config.Config) (res QrzDatatableOutput, err error) {
qrzinputjson := new(QrzDatatableInput)
var count int
var countfiltered int
var counttotal int
if err = c.Bind(qrzinputjson); err != nil {
return
}
query, querycountfiltered, querycounttotal, err := BuildQuery(config, *qrzinputjson)
rows, err := config.Db.Queryx(query)
rows, err := BuildQuery(config, *qrzinputjson)
if err != nil {
fmt.Println(err)
return
}
for rows.Next() {
var line []string
results := make(map[string]interface{})
@ -71,55 +83,87 @@ func Run(c echo.Context, config config.Config) (res QrzDatatableOutput, err erro
}
rows.Close()
rowscountfiltered, err := config.Db.Queryx(querycountfiltered)
rowscountfiltered.Next()
err = rowscountfiltered.Scan(&countfiltered)
rowscountfiltered.Close()
rowscounttotal, err := config.Db.Queryx(querycounttotal)
rowscounttotal.Next()
err = rowscounttotal.Scan(&counttotal)
rowscounttotal.Close()
res.RecordsFiltered = countfiltered
res.RecordsTotal = counttotal
res.RecordsFiltered, err = BuildQueryCountFiltered(config, *qrzinputjson)
res.RecordsTotal, err = BuildQueryCountTotal(config, *qrzinputjson)
res.Draw = qrzinputjson.Draw
return
}
// BuildQuery builds query for SQL engine
func BuildQuery(config config.Config, qrzdt QrzDatatableInput) (query string, querycountfiltered string, querycounttotal string, err error) {
selectstatement, err := SetSelectStatement(config, qrzdt)
orderstatement, err := SetOrderStatement(config, qrzdt)
limitstatement, err := SetLimitStatement(config, qrzdt)
searchstatement, err := SetSearchStatement(config, qrzdt)
// BuildQuery builds main query
func BuildQuery(config config.Config, qrzdt QrzDatatableInput) (rows *sqlx.Rows, err error) {
var selectstatement, orderstatement, limitstatement, searchstatement string
selectstatement, err = SetSelectStatement(config, qrzdt)
if err != nil {
return nil, err
}
orderstatement, err = SetOrderStatement(config, qrzdt)
if err != nil {
return nil, err
}
limitstatement, err = SetLimitStatement(config, qrzdt)
if err != nil {
return nil, err
}
searchstatement, err = SetSearchStatement(config, qrzdt)
if err != nil {
return nil, err
}
rows, err = config.Db.Queryx(fmt.Sprintf("SELECT %s FROM %s WHERE %s %s %s;", selectstatement, config.DbTable, searchstatement, orderstatement, limitstatement))
return
}
// BuildQueryCountFiltered builds query for counting filtered
func BuildQueryCountFiltered(config config.Config, qrzdt QrzDatatableInput) (cnt int, err error) {
searchstatement, err := SetSearchStatement(config, qrzdt)
err = config.Db.Get(&cnt, fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s;", config.DbTable, searchstatement))
return
}
// BuildQueryCountTotal builds query for counting totals
func BuildQueryCountTotal(config config.Config, qrzdt QrzDatatableInput) (cnt int, err error) {
err = config.Db.Get(&cnt, fmt.Sprintf("SELECT COUNT(*) FROM %s;", config.DbTable))
query = fmt.Sprintf("SELECT %s FROM %s %s %s %s;", selectstatement, config.DbTable, searchstatement, orderstatement, limitstatement)
querycountfiltered = fmt.Sprintf("SELECT COUNT(*) FROM %s %s;", config.DbTable, searchstatement)
querycounttotal = fmt.Sprintf("SELECT COUNT(*) FROM %s;", config.DbTable)
return
}
// SetSelectStatement build the sql select statement part
func SetSelectStatement(config config.Config, qrzdt QrzDatatableInput) (selectstatement string, err error) {
var cols []string
colre := regexp.MustCompile(`^[a-z]+$`)
if len(qrzdt.Columns) > 0 {
for _, col := range qrzdt.Columns {
cols = append(cols, col.Name)
valid := colre.MatchString(col.Name)
if valid {
cols = append(cols, col.Name)
} else {
err = fmt.Errorf("String does not match prerequisites")
return
}
}
selectstatement = strings.Join(cols, ", ")
selectstatement = strings.Join(cols, ",")
} else {
selectstatement = "*"
}
return
}
// SetOrderStatement build the sql order statement part
func SetOrderStatement(config config.Config, qrzdt QrzDatatableInput) (orderstmt string, err error) {
var orderstmts []string
colre := regexp.MustCompile(`^[a-z]+$`)
orderre := regexp.MustCompile(`^(ASC|asc|DESC|desc)$`)
for _, col := range qrzdt.Order {
orderstmts = append(orderstmts, fmt.Sprintf("%s %s", qrzdt.Columns[col.Column].Name, strings.ToUpper(col.Dir)))
if colre.MatchString(qrzdt.Columns[col.Column].Name) && orderre.MatchString(col.Dir) {
orderstmts = append(orderstmts, fmt.Sprintf("%s %s", qrzdt.Columns[col.Column].Name, col.Dir))
} else {
err = fmt.Errorf("Order statements does not match prerequisites")
return
}
}
if len(orderstmts) > 0 {
orderstmt = fmt.Sprintf("ORDER BY %s", strings.Join(orderstmts, ", "))
@ -129,10 +173,16 @@ func SetOrderStatement(config config.Config, qrzdt QrzDatatableInput) (orderstmt
// SetLimitStatement build the sql limit statement part
func SetLimitStatement(config config.Config, qrzdt QrzDatatableInput) (limitstmt string, err error) {
intre := regexp.MustCompile(`^[0-9]+$`)
if qrzdt.Length < 1 {
qrzdt.Length = 50
}
limitstmt = fmt.Sprintf("LIMIT %d OFFSET %d", qrzdt.Length, qrzdt.Start)
if intre.MatchString(fmt.Sprintf("%d", qrzdt.Length)) && intre.MatchString(fmt.Sprintf("%d", qrzdt.Start)) {
limitstmt = fmt.Sprintf("LIMIT %d OFFSET %d", qrzdt.Length, qrzdt.Start)
} else {
err = fmt.Errorf("Limit statements does not match prerequisites")
return
}
return
}
@ -140,28 +190,37 @@ func SetLimitStatement(config config.Config, qrzdt QrzDatatableInput) (limitstmt
func SetSearchStatement(config config.Config, qrzdt QrzDatatableInput) (searchstmt string, err error) {
var searchstmtslice []string
if len(qrzdt.Columns) > 0 {
searchstmtslice = append(searchstmtslice, "WHERE")
for id, i := range qrzdt.Columns {
searchstmtslice = append(searchstmtslice, fmt.Sprintf("%s LIKE '%%%s%%'", i.Name, qrzdt.Search.Value))
if id < len(qrzdt.Columns)-1 {
searchstmtslice = append(searchstmtslice, "OR")
}
}
searchstmt = strings.Join(searchstmtslice, " ")
} else {
searchstmtslice = []string{"1=1"}
}
searchstmt = strings.Join(searchstmtslice, " ")
return
}
// getCurrentEntries fetch existing entries from database
func getCurrentEntries(config config.Config) (existingQRZ []string, err error) {
q := fmt.Sprintf(config.Statements.DbGetQrz, config.DbTable)
rows, err := config.Db.Query(q)
// RunCSVExport runs the main loop
func RunCSVExport(c echo.Context, config config.Config) (data []byte, mime string, err error) {
mime = "text/csv"
rows, err := config.Db.Queryx(fmt.Sprintf(config.DbStatements.ExportCSV, config.DbTable))
var res []string
for rows.Next() {
var qrzelem string
rows.Scan(&qrzelem)
existingQRZ = append(existingQRZ, qrzelem)
var l []string
results := make(map[string]interface{})
err = rows.MapScan(results)
colslice, _ := rows.Columns()
for _, column := range colslice {
l = append(l, fmt.Sprintf("%s", results[column]))
}
line := strings.Join(l, ",")
res = append(res, line)
}
data = []byte(strings.Join(res, "\n"))
return
}

7
static/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

7
static/js/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

3
static/js/main.js Normal file
View File

@ -0,0 +1,3 @@
function export_frs() {
var w = window.location.href = "/export_frs.csv";
}

View File

@ -1,4 +1,4 @@
$(document).ready(function() {
$(document).ready(function () {
$('#main_table').DataTable({
"display": true,
"cell-border": true,
@ -9,27 +9,39 @@ $(document).ready(function() {
"info": "Showing page _PAGE_ of _PAGES_",
"search": "Search :",
"paginate": {
"first": "First",
"last": "Last",
"next": "Next",
"previous": "Previous"
"first": "First",
"last": "Last",
"next": "Next",
"previous": "Previous"
},
},
"stateSave": true,
"ajax": {
"url": "qrzws",
"type": "POST",
"contentType": "application/json",
"data": function(d) {
return JSON.stringify(d);
},
},
"columns": [
{ "name": "qrz" },
{ "name": "name" },
{ "name": "city" },
{ "name": "dept" },
{ "name": "country" },
]
});
});
"stateSave": true,
"ajax": {
"url": "/qrzws",
"type": "POST",
"contentType": "application/json",
"data": function (d) {
return JSON.stringify(d);
},
},
"columns": [{
"name": "qrz"
},
{
"name": "name"
},
{
"name": "city"
},
{
"name": "dept"
},
{
"name": "country"
},
{
"name": "dmrid"
},
]
});
});

View File

@ -1,30 +1,40 @@
<html>
<head>
<head>
<link rel="stylesheet" type="text/css" href="static/css/main.css" media="screen">
<link rel="stylesheet" type="text/css" href="static/css/jquery.dataTables.min.css" media="screen">
<link rel="stylesheet" type="text/css" href="static/css/font-awesome/all.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="static/css/bootstrap.min.css">
<script type="text/javascript" src="static/js/jquery.js"></script>
<script type="text/javascript" src="static/js/jquery.dataTables.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
<script type="text/javascript" src="static/js/bootstrap.min.js"></script>
<script type="text/javascript" src="static/js/main_table.js"></script>
</head>
<body>
<h1 class="jumbotron-heading">FRS QRZ database</h1>
<div>
<table id="main_table" class="display cell-border">
<thead class="thead-dark">
<tr>
<td>QRZ</td>
<td>Name</td>
<td>City</td>
<td>Department</td>
<td>Country</td>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</body>
</html>
<script type="text/javascript" src="static/js/main.js"></script>
</head>
<body>
<h1 class="jumbotron-heading">FRS QRZ database</h1>
<p>Mirror of <a href="http://groupe-frs.hamstation.eu">http://groupe-frs.hamstation.eu</a> list of users</p>
<div>
<table id="main_table" class="display cell-border">
<thead class="thead-dark">
<tr>
<td>QRZ</td>
<td>Name</td>
<td>City</td>
<td>Department</td>
<td>Country</td>
<td>DMRID</td>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
<div>
<input type="button" value="Export CSV" onclick="export_frs()">
</div>
</body>
</html>