SmartAudio/package/luci/luci-app-radicale/luasrc/model/cbi/radicale.lua

749 lines
26 KiB
Lua

-- Copyright 2015-2016 Christian Schoenebeck <christian dot schoenebeck at gmail dot com>
-- Licensed under the Apache License, Version 2.0
local NXFS = require("nixio.fs")
local DISP = require("luci.dispatcher")
local DTYP = require("luci.cbi.datatypes")
local HTTP = require("luci.http")
local UTIL = require("luci.util")
local UCI = require("luci.model.uci")
local SYS = require("luci.sys")
local WADM = require("luci.tools.webadmin")
local CTRL = require("luci.controller.radicale") -- this application's controller and multiused functions
-- #################################################################################################
-- Error handling if not installed or wrong version -- #########################
if not CTRL.service_ok() then
local f = SimpleForm("__sf")
f.title = CTRL.app_title_main()
f.description = CTRL.app_description()
f.embedded = true
f.submit = false
f.reset = false
local s = f:section(SimpleSection)
s.title = [[<font color="red">]] .. [[<strong>]]
.. translate("Software update required")
.. [[</strong>]] .. [[</font>]]
local v = s:option(DummyValue, "_dv")
v.rawhtml = true
v.value = CTRL.app_err_value
return f
end
-- #################################################################################################
-- Error handling if no config, create an empty one -- #########################
if not NXFS.access("/etc/config/radicale") then
NXFS.writefile("/etc/config/radicale", "")
end
-- #################################################################################################
-- takeover arguments if any -- ################################################
-- then show/edit selected file
if arg[1] then
local argument = arg[1]
local filename = ""
-- SimpleForm ------------------------------------------------
local ft = SimpleForm("_text")
ft.title = CTRL.app_title_back()
ft.description = CTRL.app_description()
ft.redirect = DISP.build_url("admin", "services", "radicale") .. "#cbi-radicale-" .. argument
if argument == "logger" then
ft.reset = false
ft.submit = translate("Reload")
local uci = UCI.cursor()
filename = uci:get("radicale", "logger", "file_path") or "/var/log/radicale"
uci:unload("radicale")
filename = filename .. "/radicale"
elseif argument == "auth" then
ft.submit = translate("Save")
filename = "/etc/radicale/users"
elseif argument == "rights" then
ft.submit = translate("Save")
filename = "/etc/radicale/rights"
else
error("Invalid argument given as section")
end
if argument ~= "logger" and not NXFS.access(filename) then
NXFS.writefile(filename, "")
end
-- SimpleSection ---------------------------------------------
local fs = ft:section(SimpleSection)
if argument == "logger" then
fs.title = translate("Log-file Viewer")
fs.description = translate("Please press [Reload] button below to reread the file.")
elseif argument == "auth" then
fs.title = translate("Authentication")
fs.description = translate("Place here the 'user:password' pairs for your users which should have access to Radicale.")
.. [[<br /><strong>]]
.. translate("Keep in mind to use the correct hashing algorithm !")
.. [[</strong>]]
else -- rights
fs.title = translate("Rights")
fs.description = translate("Authentication login is matched against the 'user' key, "
.. "and collection's path is matched against the 'collection' key.") .. " "
.. translate("You can use Python's ConfigParser interpolation values %(login)s and %(path)s.") .. " "
.. translate("You can also get groups from the user regex in the collection with {0}, {1}, etc.")
.. [[<br />]]
.. translate("For example, for the 'user' key, '.+' means 'authenticated user'" .. " "
.. "and '.*' means 'anybody' (including anonymous users).")
.. [[<br />]]
.. translate("Section names are only used for naming the rule.")
.. [[<br />]]
.. translate("Leading or ending slashes are trimmed from collection's path.")
end
-- TextValue -------------------------------------------------
local tt = fs:option(TextValue, "_textvalue")
tt.rmempty = true
if argument == "logger" then
tt.readonly = true
tt.rows = 30
function tt.write()
HTTP.redirect(DISP.build_url("admin", "services", "radicale", "edit", argument))
end
else
tt.rows = 15
function tt.write(self, section, value)
if not value then value = "" end
NXFS.writefile(filename, value:gsub("\r\n", "\n"))
return true --HTTP.redirect(DISP.build_url("admin", "services", "radicale", "edit") .. "#cbi-radicale-" .. argument)
end
end
function tt.cfgvalue()
return NXFS.readfile(filename) or
string.format(translate("File '%s' not found !"), filename)
end
return ft
end
-- cbi-map -- ##################################################################
local m = Map("radicale")
m.title = CTRL.app_title_main()
m.description = CTRL.app_description()
m.template = "radicale/tabmap_nsections"
m.tabbed = true
function m.commit_handler(self)
if self.changed then -- changes ?
os.execute("/etc/init.d/radicale reload &") -- reload configuration
end
end
-- cbi-section "System" -- #####################################################
local sys = m:section( NamedSection, "system", "system" )
sys.title = translate("System")
sys.description = nil
function sys.cfgvalue(self, section)
if not self.map:get(section) then -- section might not exist
self.map:set(section, nil, self.sectiontype)
end
return self.map:get(section)
end
-- start/stop button -----------------------------------------------------------
local btn = sys:option(DummyValue, "_startstop")
btn.template = "radicale/btn_startstop"
btn.inputstyle = nil
btn.rmempty = true
btn.title = translate("Start / Stop")
btn.description = translate("Start/Stop Radicale server")
function btn.cfgvalue(self, section)
local pid = CTRL.get_pid(true)
if pid > 0 then
btn.inputtitle = "PID: " .. pid
btn.inputstyle = "reset"
btn.disabled = false
else
btn.inputtitle = translate("Start")
btn.inputstyle = "apply"
btn.disabled = false
end
return true
end
-- enabled ---------------------------------------------------------------------
local ena = sys:option(Flag, "_enabled")
ena.title = translate("Auto-start")
ena.description = translate("Enable/Disable auto-start of Radicale on system start-up and interface events")
ena.orientation = "horizontal" -- put description under the checkbox
ena.rmempty = false -- force write() function
function ena.cfgvalue(self, section)
return (SYS.init.enabled("radicale")) and self.enabled or self.disabled
end
function ena.write(self, section, value)
if value == self.enabled then
return SYS.init.enable("radicale")
else
return SYS.init.disable("radicale")
end
end
-- boot_delay ------------------------------------------------------------------
local bd = sys:option(Value, "boot_delay")
bd.title = translate("Boot delay")
bd.description = translate("Delay (in seconds) during system boot before Radicale start")
.. [[<br />]]
.. translate("During delay ifup-events are not monitored !")
bd.default = "10"
function bd.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
function bd.validate(self, value)
local val = tonumber(value)
if not val then
return nil, self.title .. ": " .. translate("Value is not a number")
elseif val < 0 or val > 300 then
return nil, self.title .. ": " .. translate("Value not between 0 and 300")
end
return value
end
-- cbi-section "Server" -- #####################################################
local srv = m:section( NamedSection, "server", "setting" )
srv.title = translate("Server")
srv.description = nil
function srv.cfgvalue(self, section)
if not self.map:get(section) then -- section might not exist
self.map:set(section, nil, self.sectiontype)
end
return self.map:get(section)
end
-- hosts -----------------------------------------------------------------------
local sh = srv:option( DynamicList, "hosts" )
sh.title = translate("Address:Port")
sh.description = translate("'Hostname:Port' or 'IPv4:Port' or '[IPv6]:Port' Radicale should listen on")
.. [[<br /><strong>]]
.. translate("Port numbers below 1024 (Privileged ports) are not supported")
.. [[</strong>]]
sh.placeholder = "0.0.0.0:5232"
sh.rmempty = true
function sh.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
-- realm -----------------------------------------------------------------------
local alm = srv:option( Value, "realm" )
alm.title = translate("Logon message")
alm.description = translate("Message displayed in the client when a password is needed.")
alm.default = "Radicale - Password Required"
function alm.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
function alm.validate(self, value)
if value then
return value
else
return self.default
end
end
-- ssl -------------------------------------------------------------------------
local ssl = srv:option( Flag, "ssl" )
ssl.title = translate("Enable HTTPS")
ssl.description = nil
function ssl.write(self, section, value)
if value == "0" then -- delete all if not https enabled
self.map:del(section, "protocol") -- protocol
self.map:del(section, "certificate") -- certificate
self.map:del(section, "key") -- private key
self.map:del(section, "ciphers") -- ciphers
return self.map:del(section, self.option)
else
return self.map:set(section, self.option, value)
end
end
-- protocol --------------------------------------------------------------------
local prt = srv:option( ListValue, "protocol" )
prt.title = translate("SSL Protocol")
prt.description = translate("'AUTO' selects the highest protocol version that client and server support.")
prt.widget = "select"
prt.default = "PROTOCOL_SSLv23"
prt:depends ("ssl", "1")
prt:value ("PROTOCOL_SSLv23", translate("AUTO"))
prt:value ("PROTOCOL_SSLv2", "SSL v2")
prt:value ("PROTOCOL_SSLv3", "SSL v3")
prt:value ("PROTOCOL_TLSv1", "TLS v1")
prt:value ("PROTOCOL_TLSv1_1", "TLS v1.1")
prt:value ("PROTOCOL_TLSv1_2", "TLS v1.2")
function prt.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
-- certificate -----------------------------------------------------------------
local crt = srv:option( Value, "certificate" )
crt.title = translate("Certificate file")
crt.description = translate("Full path and file name of certificate")
crt.placeholder = "/etc/radicale/ssl/server.crt"
crt:depends ("ssl", "1")
function crt.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
function crt.validate(self, value)
local _ssl = ssl:formvalue(srv.section) or "0"
if _ssl == "0" then
return "" -- ignore if not https enabled
end
if value then -- otherwise errors in datatype check
if DTYP.file(value) then
return value
else
return nil, self.title .. ": " .. translate("File not found !")
end
else
return nil, self.title .. ": " .. translate("Path/File required !")
end
end
-- key -------------------------------------------------------------------------
local key = srv:option( Value, "key" )
key.title = translate("Private key file")
key.description = translate("Full path and file name of private key")
key.placeholder = "/etc/radicale/ssl/server.key"
key:depends ("ssl", "1")
function key.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
function key.validate(self, value)
local _ssl = ssl:formvalue(srv.section) or "0"
if _ssl == "0" then
return "" -- ignore if not https enabled
end
if value then -- otherwise errors in datatype check
if DTYP.file(value) then
return value
else
return nil, self.title .. ": " .. translate("File not found !")
end
else
return nil, self.title .. ": " .. translate("Path/File required !")
end
end
-- ciphers ---------------------------------------------------------------------
--local cip = srv:option( Value, "ciphers" )
--cip.title = translate("Ciphers")
--cip.description = translate("OPTIONAL: See python's ssl module for available ciphers")
--cip.rmempty = true
--cip:depends ("ssl", "1")
-- cbi-section "Authentication" -- #############################################
local aut = m:section( NamedSection, "auth", "setting" )
aut.title = translate("Authentication")
aut.description = translate("Authentication method to allow access to Radicale server.")
function aut.cfgvalue(self, section)
if not self.map:get(section) then -- section might not exist
self.map:set(section, nil, self.sectiontype)
end
return self.map:get(section)
end
-- type -----------------------------------------------------------------------
local aty = aut:option( ListValue, "type" )
aty.title = translate("Authentication method")
aty.description = nil
aty.widget = "select"
aty.default = "None"
aty:value ("None", translate("None"))
aty:value ("htpasswd", translate("htpasswd file"))
--aty:value ("IMAP", "IMAP") -- The IMAP authentication module relies on the imaplib module.
--aty:value ("LDAP", "LDAP") -- The LDAP authentication module relies on the python-ldap module.
--aty:value ("PAM", "PAM") -- The PAM authentication module relies on the python-pam module.
--aty:value ("courier", "courier")
--aty:value ("HTTP", "HTTP") -- The HTTP authentication module relies on the requests module
--aty:value ("remote_user", "remote_user")
--aty:value ("custom", translate("custom"))
function aty.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
function aty.write(self, section, value)
if value ~= "htpasswd" then
self.map:del(section, "htpasswd_encryption")
elseif value ~= "IMAP" then
self.map:del(section, "imap_hostname")
self.map:del(section, "imap_port")
self.map:del(section, "imap_ssl")
end
if value ~= self.default then
return self.map:set(section, self.option, value)
else
return self.map:del(section, self.option)
end
end
-- htpasswd_encryption ---------------------------------------------------------
local hte = aut:option( ListValue, "htpasswd_encryption" )
hte.title = translate("Encryption method")
hte.description = nil
hte.widget = "select"
hte.default = "crypt"
hte:depends ("type", "htpasswd")
hte:value ("crypt", translate("crypt"))
hte:value ("plain", translate("plain"))
hte:value ("sha1", translate("SHA-1"))
hte:value ("ssha", translate("salted SHA-1"))
function hte.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
-- htpasswd_file (dummy) -------------------------------------------------------
local htf = aut:option( Value, "_htf" )
htf.title = translate("htpasswd file")
htf.description = [[<strong>]]
.. translate("Read only!")
.. [[</strong> ]]
.. translate("Radicale uses '/etc/radicale/users' as htpasswd file.")
.. [[<br /><a href="]]
.. DISP.build_url("admin", "services", "radicale", "edit") .. [[/auth]]
.. [[">]]
.. translate("To edit the file follow this link!")
.. [[</a>]]
htf.readonly = true
htf:depends ("type", "htpasswd")
function htf.cfgvalue()
return "/etc/radicale/users"
end
-- cbi-section "Rights" -- #####################################################
local rig = m:section( NamedSection, "rights", "setting" )
rig.title = translate("Rights")
rig.description = translate("Control the access to data collections.")
function rig.cfgvalue(self, section)
if not self.map:get(section) then -- section might not exist
self.map:set(section, nil, self.sectiontype)
end
return self.map:get(section)
end
-- type -----------------------------------------------------------------------
local rty = rig:option( ListValue, "type" )
rty.title = translate("Rights backend")
rty.description = nil
rty.widget = "select"
rty.default = "None"
rty:value ("None", translate("Full access for everybody (including anonymous)"))
rty:value ("authenticated", translate("Full access for authenticated Users") )
rty:value ("owner_only", translate("Full access for Owner only") )
rty:value ("owner_write", translate("Owner allow write, authenticated users allow read") )
rty:value ("from_file", translate("Rights are based on a regexp-based file") )
--rty:value ("custom", "Custom handler")
function rty.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
function rty.write(self, section, value)
if value ~= "custom" then
self.map:del(section, "custom_handler")
end
if value ~= self.default then
return self.map:set(section, self.option, value)
else
return self.map:del(section, self.option)
end
end
-- from_file (dummy) -----------------------------------------------------------
local rtf = rig:option( Value, "_rtf" )
rtf.title = translate("RegExp file")
rtf.description = [[<strong>]]
.. translate("Read only!")
.. [[</strong> ]]
.. translate("Radicale uses '/etc/radicale/rights' as regexp-based file.")
.. [[<br /><a href="]]
.. DISP.build_url("admin", "services", "radicale", "edit") .. [[/rights]]
.. [[">]]
.. translate("To edit the file follow this link!")
.. [[</a>]]
rtf.readonly = true
rtf:depends ("type", "from_file")
function rtf.cfgvalue()
return "/etc/radicale/rights"
end
-- cbi-section "Storage" -- ####################################################
local sto = m:section( NamedSection, "storage", "setting" )
sto.title = translate("Storage")
sto.description = nil
function sto.cfgvalue(self, section)
if not self.map:get(section) then -- section might not exist
self.map:set(section, nil, self.sectiontype)
end
return self.map:get(section)
end
-- type -----------------------------------------------------------------------
local sty = sto:option( ListValue, "type" )
sty.title = translate("Storage backend")
sty.description = translate("WARNING: Only 'File-system' is documented and tested by Radicale development")
sty.widget = "select"
sty.default = "filesystem"
sty:value ("filesystem", translate("File-system"))
--sty:value ("multifilesystem", translate("") )
--sty:value ("database", translate("Database") )
--sty:value ("custom", translate("Custom") )
function sty.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
function sty.write(self, section, value)
if value ~= "filesystem" then
self.map:del(section, "filesystem_folder")
end
if value ~= self.default then
return self.map:set(section, self.option, value)
else
return self.map:del(section, self.option)
end
end
--filesystem_folder ------------------------------------------------------------
local sfi = sto:option( Value, "filesystem_folder" )
sfi.title = translate("Directory")
sfi.description = nil
sfi.placeholder = "/srv/radicale"
sfi:depends ("type", "filesystem")
function sfi.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
function sfi.validate(self, value)
local _typ = sty:formvalue(sto.section) or ""
if _typ ~= "filesystem" then
return "" -- ignore if not htpasswd
end
if value then -- otherwise errors in datatype check
if DTYP.directory(value) then
return value
else
return nil, self.title .. ": " .. translate("Directory not exists/found !")
end
else
return nil, self.title .. ": " .. translate("Directory required !")
end
end
-- cbi-section "Logging" -- ####################################################
local log = m:section( NamedSection, "logger", "logging" )
log.title = translate("Logging")
log.description = nil
function log.cfgvalue(self, section)
if not self.map:get(section) then -- section might not exist
self.map:set(section, nil, self.sectiontype)
end
return self.map:get(section)
end
-- console_level ---------------------------------------------------------------
local lco = log:option( ListValue, "console_level" )
lco.title = translate("Console Log level")
lco.description = nil
lco.widget = "select"
lco.default = "ERROR"
lco:value ("DEBUG", translate("Debug"))
lco:value ("INFO", translate("Info") )
lco:value ("WARNING", translate("Warning") )
lco:value ("ERROR", translate("Error") )
lco:value ("CRITICAL", translate("Critical") )
function lco.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
function lco.write(self, section, value)
if value ~= self.default then
return self.map:set(section, self.option, value)
else
return self.map:del(section, self.option)
end
end
-- syslog_level ----------------------------------------------------------------
local lsl = log:option( ListValue, "syslog_level" )
lsl.title = translate("Syslog Log level")
lsl.description = nil
lsl.widget = "select"
lsl.default = "WARNING"
lsl:value ("DEBUG", translate("Debug"))
lsl:value ("INFO", translate("Info") )
lsl:value ("WARNING", translate("Warning") )
lsl:value ("ERROR", translate("Error") )
lsl:value ("CRITICAL", translate("Critical") )
function lsl.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
function lsl.write(self, section, value)
if value ~= self.default then
return self.map:set(section, self.option, value)
else
return self.map:del(section, self.option)
end
end
-- file_level ------------------------------------------------------------------
local lfi = log:option( ListValue, "file_level" )
lfi.title = translate("File Log level")
lfi.description = nil
lfi.widget = "select"
lfi.default = "INFO"
lfi:value ("DEBUG", translate("Debug"))
lfi:value ("INFO", translate("Info") )
lfi:value ("WARNING", translate("Warning") )
lfi:value ("ERROR", translate("Error") )
lfi:value ("CRITICAL", translate("Critical") )
function lfi.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
function lfi.write(self, section, value)
if value ~= self.default then
return self.map:set(section, self.option, value)
else
return self.map:del(section, self.option)
end
end
-- file_path -------------------------------------------------------------------
local lfp = log:option( Value, "file_path" )
lfp.title = translate("Log-file directory")
lfp.description = translate("Directory where the rotating log-files are stored")
.. [[<br /><a href="]]
.. DISP.build_url("admin", "services", "radicale", "edit") .. [[/logger]]
.. [[">]]
.. translate("To view latest log file follow this link!")
.. [[</a>]]
lfp.default = "/var/log/radicale"
function lfp.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
function lfp.validate(self, value)
if not value or (#value < 1) or (value:find("/") ~= 1) then
return nil, self.title .. ": " .. translate("no valid path given!")
end
return value
end
-- file_maxbytes ---------------------------------------------------------------
local lmb = log:option( Value, "file_maxbytes" )
lmb.title = translate("Log-file size")
lmb.description = translate("Maximum size of each rotation log-file.")
.. [[<br /><strong>]]
.. translate("Setting this parameter to '0' will disable rotation of log-file.")
.. [[</strong>]]
lmb.default = "8196"
function lmb.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
function lmb.validate(self, value)
if value then -- otherwise errors in datatype check
if DTYP.uinteger(value) then
return value
else
return nil, self.title .. ": " .. translate("Value is not an Integer >= 0 !")
end
else
return nil, self.title .. ": " .. translate("Value required ! Integer >= 0 !")
end
end
-- file_backupcount ------------------------------------------------------------
local lbc = log:option( Value, "file_backupcount" )
lbc.title = translate("Log-backup Count")
lbc.description = translate("Number of backup files of log to create.")
.. [[<br /><strong>]]
.. translate("Setting this parameter to '0' will disable rotation of log-file.")
.. [[</strong>]]
lbc.default = "1"
function lbc.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
function lbc.validate(self, value)
if value then -- otherwise errors in datatype check
if DTYP.uinteger(value) then
return value
else
return nil, self.title .. ": " .. translate("Value is not an Integer >= 0 !")
end
else
return nil, self.title .. ": " .. translate("Value required ! Integer >= 0 !")
end
end
-- cbi-section "Encoding" -- ###################################################
local enc = m:section( NamedSection, "encoding", "setting" )
enc.title = translate("Encoding")
enc.description = translate("Change here the encoding Radicale will use instead of 'UTF-8' "
.. "for responses to the client and/or to store data inside collections.")
function enc.cfgvalue(self, section)
if not self.map:get(section) then -- section might not exist
self.map:set(section, nil, self.sectiontype)
end
return self.map:get(section)
end
-- request ---------------------------------------------------------------------
local enr = enc:option( Value, "request" )
enr.title = translate("Response Encoding")
enr.description = translate("Encoding for responding requests.")
enr.default = "utf-8"
function enr.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
-- stock -----------------------------------------------------------------------
local ens = enc:option( Value, "stock" )
ens.title = translate("Storage Encoding")
ens.description = translate("Encoding for storing local collections.")
ens.default = "utf-8"
function ens.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
-- cbi-section "Headers" -- ####################################################
local hea = m:section( NamedSection, "headers", "setting" )
hea.title = translate("Additional HTTP headers")
hea.description = translate("Cross-origin resource sharing (CORS) is a mechanism that allows restricted resources (e.g. fonts, JavaScript, etc.) "
.. "on a web page to be requested from another domain outside the domain from which the resource originated.")
function hea.cfgvalue(self, section)
if not self.map:get(section) then -- section might not exist
self.map:set(section, nil, self.sectiontype)
end
return self.map:get(section)
end
-- Access_Control_Allow_Origin -------------------------------------------------
local heo = hea:option( DynamicList, "Access_Control_Allow_Origin" )
heo.title = translate("Access-Control-Allow-Origin")
heo.description = nil
function heo.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
-- Access_Control_Allow_Methods ------------------------------------------------
local hem = hea:option( DynamicList, "Access_Control_Allow_Methods" )
hem.title = translate("Access-Control-Allow-Methods")
hem.description = nil
function hem.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
-- Access_Control_Allow_Headers ------------------------------------------------
local heh = hea:option( DynamicList, "Access_Control_Allow_Headers" )
heh.title = translate("Access-Control-Allow-Headers")
heh.description = nil
function heh.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
-- Access_Control_Expose_Headers -----------------------------------------------
local hee = hea:option( DynamicList, "Access_Control_Expose_Headers" )
hee.title = translate("Access-Control-Expose-Headers")
hee.description = nil
function hee.parse(self, section, novld)
CTRL.value_parse(self, section, novld)
end
return m