Add support for custom CSS/JS in settings for admin and public pages.

This feature was originally authored by @sweetppro in PR #438.
However, since the PR ended up in an unclean state with
multiple master merges (instead of rebase) from the upstream, there are
several commits that are out of order and can can no longer be be
squashed for a clean feature merge.

This commit aggregates the changes from the original PR and applies the
following fixes on top of it.

- Add custom admin JS box to appearance UI.
- Refactor i18n language strings.
- Add handlers and migrations for the new `appearance.admin.custom_js`
  field.
- Fix migration version to `v2.1.0`
- Load custom appearance CSS/JS bytes into global constants during boot
  instead of making a DB call on every request.
- Fix and canonicalize URIs from `/api/custom*` to `/public/*.css`
  and `/admin/*.css`. Add proxy paths to yarn proxy config.
- Remove redundant HTTP handlers for different custom appearance files
  and refactor into a single handler `serveCustomApperance()`
- Fix content-type and UTF8 encoding headers for different file types.
- Fix incorrect registration of public facing custom CSS/JS handlers
  in the authenticated admin URI group.
- Fix merge conflicts in `Settings.vue`.
- Minor HTML and style fixes.
- Remove the `AppearanceEditor` component and use the existing
  `HTMLEditor` component instead.
- Add `language` prop to the `HTMLEditor` component.

Co-authored-by: SweetPPro <sweetppro@users.noreply.github.com>
This commit is contained in:
Kailash Nadh 2021-12-18 15:38:42 +05:30
parent 920645f90e
commit fabe06e339
28 changed files with 280 additions and 5 deletions

View file

@ -55,6 +55,8 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
return c.Render(http.StatusOK, "home", publicTpl{Title: "listmonk"})
})
g.GET(path.Join(adminRoot, ""), handleAdminPage)
g.GET(path.Join(adminRoot, "/custom.css"), serveCustomApperance("admin.custom_css"))
g.GET(path.Join(adminRoot, "/custom.js"), serveCustomApperance("admin.custom_js"))
g.GET(path.Join(adminRoot, "/*"), handleAdminPage)
// API endpoints.
@ -142,6 +144,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
e.POST("/webhooks/service/:service", handleBounceWebhook)
}
// /public/static/* file server is registered in initHTTPServer().
// Public subscriber facing views.
e.GET("/subscription/form", handleSubscriptionFormPage)
e.POST("/subscription/form", handleSubscriptionForm)
@ -161,6 +164,10 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
"campUUID", "subUUID")))
e.GET("/campaign/:campUUID/:subUUID/px.png", noIndex(validateUUID(handleRegisterCampaignView,
"campUUID", "subUUID")))
e.GET("/public/custom.css", serveCustomApperance("public.custom_css"))
e.GET("/public/custom.js", serveCustomApperance("public.custom_js"))
// Public health API endpoint.
e.GET("/health", handleHealthCheck)
}
@ -182,6 +189,39 @@ func handleHealthCheck(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true})
}
// serveCustomApperance serves the given custom CSS/JS apperance blob
// meant for customizing public and admin pages from the admin settings UI.
func serveCustomApperance(name string) echo.HandlerFunc {
return func(c echo.Context) error {
var (
app = c.Get("app").(*App)
out []byte
hdr string
)
switch name {
case "admin.custom_css":
out = app.constants.Appearance.AdminCSS
hdr = "text/css; charset=utf-8"
case "admin.custom_js":
out = app.constants.Appearance.AdminJS
hdr = "application/javascript; charset=utf-8"
case "public.custom_css":
out = app.constants.Appearance.PublicCSS
hdr = "text/css; charset=utf-8"
case "public.custom_js":
out = app.constants.Appearance.PublicJS
hdr = "application/javascript; charset=utf-8"
}
return c.Blob(http.StatusOK, hdr, out)
}
}
// basicAuth middleware does an HTTP BasicAuth authentication for admin handlers.
func basicAuth(username, password string, c echo.Context) (bool, error) {
app := c.Get("app").(*App)

View file

@ -68,6 +68,13 @@ type constants struct {
AdminUsername []byte `koanf:"admin_username"`
AdminPassword []byte `koanf:"admin_password"`
Appearance struct {
AdminCSS []byte `koanf:"admin.custom_css"`
AdminJS []byte `koanf:"admin.custom_js"`
PublicCSS []byte `koanf:"public.custom_css"`
PublicJS []byte `koanf:"public.custom_js"`
}
UnsubURL string
LinkTrackURL string
ViewTrackURL string
@ -293,7 +300,10 @@ func initConstants() *constants {
lo.Fatalf("error loading app config: %v", err)
}
if err := ko.Unmarshal("privacy", &c.Privacy); err != nil {
lo.Fatalf("error loading app config: %v", err)
lo.Fatalf("error loading app.privacy config: %v", err)
}
if err := ko.UnmarshalWithConf("appearance", &c.Appearance, koanf.UnmarshalConf{FlatPaths: true}); err != nil {
lo.Fatalf("error loading app.appearance config: %v", err)
}
c.RootURL = strings.TrimRight(c.RootURL, "/")
@ -622,7 +632,7 @@ func initHTTPServer(app *App) *echo.Echo {
fSrv := app.fs.FileServer()
// Public (subscriber) facing static files.
srv.GET("/public/*", echo.WrapHandler(fSrv))
srv.GET("/public/static/*", echo.WrapHandler(fSrv))
// Admin (frontend) facing static files.
srv.GET("/admin/static/*", echo.WrapHandler(fSrv))

View file

@ -105,6 +105,11 @@ type settings struct {
TLSSkipVerify bool `json:"tls_skip_verify"`
ScanInterval string `json:"scan_interval"`
} `json:"bounce.mailboxes"`
AdminCustomCSS string `json:"appearance.admin.custom_css"`
AdminCustomJS string `json:"appearance.admin.custom_js"`
PublicCustomCSS string `json:"appearance.public.custom_css"`
PublicCustomJS string `json:"appearance.public.custom_js"`
}
var (

View file

@ -31,6 +31,7 @@ var migList = []migFunc{
{"v0.9.0", migrations.V0_9_0},
{"v1.0.0", migrations.V1_0_0},
{"v2.0.0", migrations.V2_0_0},
{"v2.1.0", migrations.V2_1_0},
}
// upgrade upgrades the database to the current version by running SQL migration files

View file

@ -5,6 +5,8 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>static/favicon.png" />
<link href="<%= BASE_URL %>custom.css" rel="stylesheet" type="text/css">
<script src="<%= BASE_URL %>custom.js" async defer></script>
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>

View file

@ -802,6 +802,10 @@ section.analytics {
.box {
margin-bottom: 30px;
}
.html-editor {
height: auto;
min-height: 350px;
}
}
/* Logs */

View file

@ -9,6 +9,10 @@ import { colors } from '../constants';
export default {
props: {
value: String,
language: {
type: String,
default: 'html',
},
disabled: Boolean,
},
@ -38,7 +42,7 @@ export default {
this.$refs.htmlEditor.appendChild(el);
this.flask = new CodeFlask(el.shadowRoot.getElementById('area'), {
language: 'html',
language: this.$props.language,
lineNumbers: false,
styleParent: el.shadowRoot,
readonly: this.disabled,

View file

@ -49,6 +49,10 @@
<b-tab-item :label="$t('settings.messengers.name')">
<messenger-settings :form="form" :key="key" />
</b-tab-item><!-- messengers -->
<b-tab-item :label="$t('settings.appearance.name')">
<appearance-settings :form="form" :key="key" />
</b-tab-item><!-- appearance -->
</b-tabs>
</section>
@ -66,6 +70,7 @@ import MediaSettings from './settings/media.vue';
import SmtpSettings from './settings/smtp.vue';
import BounceSettings from './settings/bounces.vue';
import MessengerSettings from './settings/messengers.vue';
import AppearanceSettings from './settings/appearance.vue';
const dummyPassword = ' '.repeat(8);
@ -78,6 +83,7 @@ export default Vue.extend({
SmtpSettings,
BounceSettings,
MessengerSettings,
AppearanceSettings,
},
data() {

View file

@ -0,0 +1,66 @@
<template>
<div class="items">
<b-tabs :animated="false">
<b-tab-item :label="$t('settings.appearance.adminName')" label-position="on-border">
<div class="block">
{{ $t('settings.appearance.adminHelp') }}
</div>
<b-field :label="$t('settings.appearance.customCSS')" label-position="on-border">
<html-editor v-model="data['appearance.admin.custom_css']" name="body"
language="css" />
</b-field>
<b-field :label="$t('settings.appearance.customJS')" label-position="on-border">
<html-editor v-model="data['appearance.admin.custom_js']" name="body"
language="css" />
</b-field>
</b-tab-item><!-- admin -->
<b-tab-item :label="$t('settings.appearance.publicName')" label-position="on-border">
<div class="block">
{{ $t('settings.appearance.publicHelp') }}
</div>
<b-field :label="$t('settings.appearance.customCSS')" label-position="on-border">
<html-editor v-model="data['appearance.public.custom_css']" name="body"
language="css" />
</b-field>
<b-field :label="$t('settings.appearance.customJS')" label-position="on-border">
<html-editor v-model="data['appearance.public.custom_js']" name="body"
language="js" />
</b-field>
</b-tab-item><!-- public -->
</b-tabs>
</div>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import HTMLEditor from '../../components/HTMLEditor.vue';
export default Vue.extend({
components: {
'html-editor': HTMLEditor,
},
props: {
form: {
type: Object,
},
},
data() {
return {
data: this.form,
};
},
computed: {
...mapState(['settings']),
},
});
</script>

View file

@ -29,7 +29,10 @@ module.exports = {
'^/$': {
target: process.env.LISTMONK_API_URL || 'http://127.0.0.1:9000'
},
'^/(api|webhooks|subscription|public)': {
'^/(api|webhooks|subscription|public|health)': {
target: process.env.LISTMONK_API_URL || 'http://127.0.0.1:9000'
},
'^/(admin\/custom\.(css|js))': {
target: process.env.LISTMONK_API_URL || 'http://127.0.0.1:9000'
}
}

View file

@ -295,6 +295,13 @@
"public.unsubbedInfo": "Odběr jste zrušili úspěšně.",
"public.unsubbedTitle": "Zrušen odběr",
"public.unsubscribeTitle": "Zrušit odběr ze seznamu adresátů",
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
"settings.appearance.adminName": "Admin",
"settings.appearance.customCSS": "Custom CSS",
"settings.appearance.customJS": "Custom JavaScript",
"settings.appearance.name": "Appearance",
"settings.appearance.publicHelp": "Custom CSS and JavaScript to apply to the public pages.",
"settings.appearance.publicName": "Public",
"settings.bounces.action": "Akce",
"settings.bounces.blocklist": "Seznam blokovaných",
"settings.bounces.count": "Počet případů nedoručitelnosti",

View file

@ -295,6 +295,13 @@
"public.unsubbedInfo": "Du wurdest erfolgreich abgemeldet",
"public.unsubbedTitle": "Abgemeldet",
"public.unsubscribeTitle": "Von E-Mail Liste abmelden.",
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
"settings.appearance.adminName": "Admin",
"settings.appearance.customCSS": "Custom CSS",
"settings.appearance.customJS": "Custom JavaScript",
"settings.appearance.name": "Appearance",
"settings.appearance.publicHelp": "Custom CSS and JavaScript to apply to the public pages.",
"settings.appearance.publicName": "Public",
"settings.bounces.action": "Aktion",
"settings.bounces.blocklist": "Sperrliste",
"settings.bounces.count": "Bounce Anzahl",

View file

@ -295,6 +295,13 @@
"public.unsubbedInfo": "You have unsubscribed successfully.",
"public.unsubbedTitle": "Unsubscribed",
"public.unsubscribeTitle": "Unsubscribe from mailing list",
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
"settings.appearance.adminName": "Admin",
"settings.appearance.customCSS": "Custom CSS",
"settings.appearance.customJS": "Custom JavaScript",
"settings.appearance.name": "Appearance",
"settings.appearance.publicHelp": "Custom CSS and JavaScript to apply to the public pages.",
"settings.appearance.publicName": "Public",
"settings.bounces.action": "Action",
"settings.bounces.blocklist": "Blocklist",
"settings.bounces.count": "Bounce count",

View file

@ -295,6 +295,13 @@
"public.unsubbedInfo": "Ud. se ha des-subscrito de forma satisfactoria",
"public.unsubbedTitle": "Des-subscrito.",
"public.unsubscribeTitle": "Des-subscribirse de una lista de correo",
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
"settings.appearance.adminName": "Admin",
"settings.appearance.customCSS": "Custom CSS",
"settings.appearance.customJS": "Custom JavaScript",
"settings.appearance.name": "Appearance",
"settings.appearance.publicHelp": "Custom CSS and JavaScript to apply to the public pages.",
"settings.appearance.publicName": "Public",
"settings.bounces.action": "Acción",
"settings.bounces.blocklist": "Lista de bloqueo",
"settings.bounces.count": "Conteo de rebotes",

View file

@ -295,6 +295,13 @@
"public.unsubbedInfo": "Vous vous êtes désabonné·e avec succès.",
"public.unsubbedTitle": "Désabonné·e",
"public.unsubscribeTitle": "Se désabonner de la liste de diffusion",
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
"settings.appearance.adminName": "Admin",
"settings.appearance.customCSS": "Custom CSS",
"settings.appearance.customJS": "Custom JavaScript",
"settings.appearance.name": "Appearance",
"settings.appearance.publicHelp": "Custom CSS and JavaScript to apply to the public pages.",
"settings.appearance.publicName": "Public",
"settings.bounces.action": "Action",
"settings.bounces.blocklist": "Liste de bloquage",
"settings.bounces.count": "Comptage des rebonds",

View file

@ -295,6 +295,13 @@
"public.unsubbedInfo": "Sikeresen leiratkozott.",
"public.unsubbedTitle": "Leiratkozott",
"public.unsubscribeTitle": "Leiratkozás a levelezőlistáról",
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
"settings.appearance.adminName": "Admin",
"settings.appearance.customCSS": "Custom CSS",
"settings.appearance.customJS": "Custom JavaScript",
"settings.appearance.name": "Appearance",
"settings.appearance.publicHelp": "Custom CSS and JavaScript to apply to the public pages.",
"settings.appearance.publicName": "Public",
"settings.bounces.action": "Action",
"settings.bounces.blocklist": "Tiltólista",
"settings.bounces.count": "Visszapattanások száma",

View file

@ -295,6 +295,13 @@
"public.unsubbedInfo": "La cancellazione è avvenuta con successo.",
"public.unsubbedTitle": "Iscrizione annullata",
"public.unsubscribeTitle": "Cancella l'iscrizione dalla newsletter",
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
"settings.appearance.adminName": "Admin",
"settings.appearance.customCSS": "Custom CSS",
"settings.appearance.customJS": "Custom JavaScript",
"settings.appearance.name": "Appearance",
"settings.appearance.publicHelp": "Custom CSS and JavaScript to apply to the public pages.",
"settings.appearance.publicName": "Public",
"settings.bounces.action": "Action",
"settings.bounces.blocklist": "Blocklist",
"settings.bounces.count": "Bounce count",

View file

@ -295,6 +295,13 @@
"public.unsubbedInfo": "നിങ്ങൾ വരിക്കാരനല്ലാതായി",
"public.unsubbedTitle": "വരിക്കാരനല്ലാതാകുക",
"public.unsubscribeTitle": "മെയിലിങ് ലിസ്റ്റിന്റെ വരിക്കാരനല്ലാതാകുക",
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
"settings.appearance.adminName": "Admin",
"settings.appearance.customCSS": "Custom CSS",
"settings.appearance.customJS": "Custom JavaScript",
"settings.appearance.name": "Appearance",
"settings.appearance.publicHelp": "Custom CSS and JavaScript to apply to the public pages.",
"settings.appearance.publicName": "Public",
"settings.bounces.action": "Action",
"settings.bounces.blocklist": "Blocklist",
"settings.bounces.count": "Bounce count",

View file

@ -295,6 +295,13 @@
"public.unsubbedInfo": "Je bent met succes uitgeschreven.",
"public.unsubbedTitle": "Uitgeschreven",
"public.unsubscribeTitle": "Uitschrijven van mailinglijst",
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
"settings.appearance.adminName": "Admin",
"settings.appearance.customCSS": "Custom CSS",
"settings.appearance.customJS": "Custom JavaScript",
"settings.appearance.name": "Appearance",
"settings.appearance.publicHelp": "Custom CSS and JavaScript to apply to the public pages.",
"settings.appearance.publicName": "Public",
"settings.bounces.action": "Actie",
"settings.bounces.blocklist": "Geblokkeerd",
"settings.bounces.count": "Aantal bounces",

View file

@ -295,6 +295,13 @@
"public.unsubbedInfo": "Pomyślnie odsubskrybowano",
"public.unsubbedTitle": "Odsubskrybowano",
"public.unsubscribeTitle": "Wypisz się z listy mailingowej",
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
"settings.appearance.adminName": "Admin",
"settings.appearance.customCSS": "Custom CSS",
"settings.appearance.customJS": "Custom JavaScript",
"settings.appearance.name": "Appearance",
"settings.appearance.publicHelp": "Custom CSS and JavaScript to apply to the public pages.",
"settings.appearance.publicName": "Public",
"settings.bounces.action": "Akcja",
"settings.bounces.blocklist": "Lista zablokowanych",
"settings.bounces.count": "Liczba odbić",

View file

@ -295,6 +295,13 @@
"public.unsubbedInfo": "Você cancelou a inscrição com sucesso.",
"public.unsubbedTitle": "Inscrição cancelada",
"public.unsubscribeTitle": "Cancelar inscrição na lista de e-mails",
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
"settings.appearance.adminName": "Admin",
"settings.appearance.customCSS": "Custom CSS",
"settings.appearance.customJS": "Custom JavaScript",
"settings.appearance.name": "Appearance",
"settings.appearance.publicHelp": "Custom CSS and JavaScript to apply to the public pages.",
"settings.appearance.publicName": "Public",
"settings.bounces.action": "Action",
"settings.bounces.blocklist": "Blocklist",
"settings.bounces.count": "Bounce count",

View file

@ -295,6 +295,13 @@
"public.unsubbedInfo": "A sua subscrição foi cancelada com sucesso.",
"public.unsubbedTitle": "Subscrição cancelada",
"public.unsubscribeTitle": "Cancelar subscrição da lista de emails",
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
"settings.appearance.adminName": "Admin",
"settings.appearance.customCSS": "Custom CSS",
"settings.appearance.customJS": "Custom JavaScript",
"settings.appearance.name": "Appearance",
"settings.appearance.publicHelp": "Custom CSS and JavaScript to apply to the public pages.",
"settings.appearance.publicName": "Public",
"settings.bounces.action": "Action",
"settings.bounces.blocklist": "Blocklist",
"settings.bounces.count": "Bounce count",

View file

@ -295,6 +295,13 @@
"public.unsubbedInfo": "Te-ai dezabonat cu succes.",
"public.unsubbedTitle": "Dezabonat",
"public.unsubscribeTitle": "Dezabonează-te de la lista de discuții",
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
"settings.appearance.adminName": "Admin",
"settings.appearance.customCSS": "Custom CSS",
"settings.appearance.customJS": "Custom JavaScript",
"settings.appearance.name": "Appearance",
"settings.appearance.publicHelp": "Custom CSS and JavaScript to apply to the public pages.",
"settings.appearance.publicName": "Public",
"settings.bounces.action": "Acțiune",
"settings.bounces.blocklist": "Lista de blocare",
"settings.bounces.count": "Numarul de respingeri",

View file

@ -295,6 +295,13 @@
"public.unsubbedInfo": "Вы были отписаны.",
"public.unsubbedTitle": "Отписано",
"public.unsubscribeTitle": "Отписаться от списков рассылки",
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
"settings.appearance.adminName": "Admin",
"settings.appearance.customCSS": "Custom CSS",
"settings.appearance.customJS": "Custom JavaScript",
"settings.appearance.name": "Appearance",
"settings.appearance.publicHelp": "Custom CSS and JavaScript to apply to the public pages.",
"settings.appearance.publicName": "Public",
"settings.bounces.action": "Action",
"settings.bounces.blocklist": "Blocklist",
"settings.bounces.count": "Bounce count",

View file

@ -295,6 +295,13 @@
"public.unsubbedInfo": "Başarı ile üyeliğinizi bitirdiniz.",
"public.unsubbedTitle": "Üyelik bitirildi.",
"public.unsubscribeTitle": "e-posta listesi üyeliğini bitir",
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
"settings.appearance.adminName": "Admin",
"settings.appearance.customCSS": "Custom CSS",
"settings.appearance.customJS": "Custom JavaScript",
"settings.appearance.name": "Appearance",
"settings.appearance.publicHelp": "Custom CSS and JavaScript to apply to the public pages.",
"settings.appearance.publicName": "Public",
"settings.bounces.action": "Action",
"settings.bounces.blocklist": "Blocklist",
"settings.bounces.count": "Bounce count",

View file

@ -0,0 +1,23 @@
package migrations
import (
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf"
"github.com/knadh/stuffbin"
)
// V2_1_0 performs the DB migrations for v.2.1.0.
func V2_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('appearance.admin.custom_css', '""'),
('appearance.admin.custom_js', '""'),
('appearance.public.custom_css', '""'),
('appearance.public.custom_js', '""')
ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
return nil
}

View file

@ -218,7 +218,11 @@ INSERT INTO settings (key, value) VALUES
('bounce.sendgrid_enabled', 'false'),
('bounce.sendgrid_key', '""'),
('bounce.mailboxes',
'[{"enabled":false, "type": "pop", "host":"pop.yoursite.com","port":995,"auth_protocol":"userpass","username":"username","password":"password","return_path": "bounce@listmonk.yoursite.com","scan_interval":"15m","tls_enabled":true,"tls_skip_verify":false}]');
'[{"enabled":false, "type": "pop", "host":"pop.yoursite.com","port":995,"auth_protocol":"userpass","username":"username","password":"password","return_path": "bounce@listmonk.yoursite.com","scan_interval":"15m","tls_enabled":true,"tls_skip_verify":false}]'),
('appearance.admin.custom_css', '""'),
('appearance.admin.custom_js', '""'),
('appearance.public.custom_css', '""'),
('appearance.public.custom_js', '""');
-- bounces
DROP TABLE IF EXISTS bounces CASCADE;

View file

@ -7,6 +7,8 @@
<meta name="description" content="{{ .Data.Description }}" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<link href="/public/static/style.css" rel="stylesheet" type="text/css" />
<link href="/public/custom.css" rel="stylesheet" type="text/css">
<script src="/public/custom.js" async defer></script>
{{ if ne .FaviconURL "" }}
<link rel="shortcut icon" href="{{ .FaviconURL }}" type="image/x-icon" />