diff --git a/CHANGELOG.md b/CHANGELOG.md index a553072..7b610a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ The release notes on GitHub are taken from this file: the section whose heading is the version number. +## Unreleased + +- Web UI: unregistered installations show a small sponsor banner in the left sidebar, above the language and theme picker. The browser loads it from github.com/githubflyideas/ads-sponsor (3 s timeout, no cookies, no referrer, nothing about the installation). If that fails, it uses the last set the server downloaded from GitHub (at start and every 6 hours, only while unregistered; kept in `/sponsor`, served at `/api/sponsor`); if that fails too, a built-in text box. A broken image is never shown. Registering removes it. Not shown in the TUI. + ## 2.3.8 - Documentation only: license contact updated to route66traffic66@gmail.com in the remaining manual translations (German, Portuguese, Russian, Korean, Vietnamese, Indonesian, Hindi, Bengali, Arabic, Urdu). diff --git a/README.md b/README.md index db8f9c7..e34a580 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,4 @@ macOS: run `xattr -dr com.apple.quarantine .` in the folder first. PowerShell: u Web UI: **Help** in the menu. GitHub: [MANUAL.md](docs/MANUAL.md) Free for evaluation and for organizations under 100 people ([licence](LICENSE.md)). +Unregistered builds show a small sponsor banner, loaded by your browser from GitHub or cached by the server from GitHub (no data sent); registering removes it. diff --git a/cmd/traffic66/main.go b/cmd/traffic66/main.go index f233bd7..6d21408 100644 --- a/cmd/traffic66/main.go +++ b/cmd/traffic66/main.go @@ -576,6 +576,9 @@ func serve(args []string, demo bool) { log.Printf("your browser will warn about it once; check that fingerprint, or add %s to your trusted certificates", info.CertPath) } } + if offline == nil { + go srv.RunSponsor(ctx) // sponsor banner fallback; skipped while registered + } hs := &http.Server{Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second} go hs.Serve(ln) log.Printf("web UI: %s://%s", scheme, displayAddr(ln.Addr())) diff --git a/docs/MANUAL.ar.md b/docs/MANUAL.ar.md index 8f0a6cc..0873592 100644 --- a/docs/MANUAL.ar.md +++ b/docs/MANUAL.ar.md @@ -490,6 +490,8 @@ root، وWindows إلى [Npcap](https://npcap.com). تأتي التدفقات ا `license.json` الذي تتلقاه في دليل البيانات. للتواصل: . +تعرض التثبيتات غير المسجلة لافتة رعاية صغيرة في الشريط الجانبي الأيسر. يحمّلها متصفحك من GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor))، أو إن تعذّر الوصول إليه فمن نسخة نزّلها خادم traffic66 من هناك ويحفظها في `/sponsor`؛ ولا يرسل traffic66 أي بيانات عنك أو عن التثبيت. التسجيل يزيلها. + ## 11. الأمان، تقدير الموارد، استكشاف الأخطاء diff --git a/docs/MANUAL.bn.md b/docs/MANUAL.bn.md index d355d59..c657a1f 100644 --- a/docs/MANUAL.bn.md +++ b/docs/MANUAL.bn.md @@ -499,6 +499,8 @@ available (ইংরেজি পাঠ বাধ্যতামূলক): ম পাবেন তা data directory-তে রাখুন। যোগাযোগ: । +নিবন্ধিত নয় এমন ইনস্টলেশনে বাম সাইডবারে একটি ছোট স্পনসর ব্যানার দেখা যায়। আপনার ব্রাউজার এটি GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) থেকে লোড করে, আর GitHub-এ পৌঁছাতে না পারলে traffic66 সার্ভার সেখান থেকে ডাউনলোড করে `/sponsor`-এ রাখা কপি থেকে; traffic66 আপনার বা ইনস্টলেশনের কোনো তথ্য পাঠায় না। নিবন্ধন করলে এটি সরে যায়। + ## 11. নিরাপত্তা, সক্ষমতার হিসাব, সমস্যা সমাধান diff --git a/docs/MANUAL.de.md b/docs/MANUAL.de.md index 0c3471b..fd7a249 100644 --- a/docs/MANUAL.de.md +++ b/docs/MANUAL.de.md @@ -516,6 +516,8 @@ Installationsnummer; senden Sie sie an den Autor und legen Sie die zurückgeschickte `license.json` in das Datenverzeichnis. Kontakt: . +Nicht registrierte Installationen zeigen in der linken Seitenleiste ein kleines Sponsorenbanner. Ihr Browser lädt es von GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) oder, wenn GitHub nicht erreichbar ist, aus einer Kopie, die der traffic66-Server von dort geladen hat und in `/sponsor` aufbewahrt; traffic66 sendet keinerlei Daten über Sie oder die Installation. Mit der Registrierung verschwindet es. + ## 11. Sicherheit, Dimensionierung, Fehlerbehebung diff --git a/docs/MANUAL.es.md b/docs/MANUAL.es.md index 21753ea..b6dfc53 100644 --- a/docs/MANUAL.es.md +++ b/docs/MANUAL.es.md @@ -512,6 +512,8 @@ pie de cada página muestra el número de instalación de 8 dígitos; envíelo al autor y ponga el `license.json` que reciba en el directorio de datos. Contacto: . +Las instalaciones no registradas muestran un pequeño banner de patrocinio en la barra lateral izquierda. Tu navegador lo carga desde GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) o, si no puede alcanzarlo, desde una copia que el servidor traffic66 descargó de allí y guarda en `/sponsor`; traffic66 no envía ningún dato sobre ti ni sobre la instalación. Al registrarse desaparece. + ## 11. Seguridad, dimensionamiento, solución de problemas diff --git a/docs/MANUAL.fr.md b/docs/MANUAL.fr.md index 2c91ba4..14882db 100644 --- a/docs/MANUAL.fr.md +++ b/docs/MANUAL.fr.md @@ -522,6 +522,8 @@ d'installation à 8 chiffres ; envoyez-le à l'auteur et placez le `license.json` reçu en retour dans le répertoire de données. Contact : . +Les installations non enregistrées affichent une petite bannière de sponsor dans la barre latérale gauche. Votre navigateur la charge depuis GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) ou, s'il ne peut pas l'atteindre, depuis une copie que le serveur traffic66 y a téléchargée et garde dans `/sponsor` ; traffic66 n'envoie aucune donnée sur vous ni sur l'installation. L'enregistrement la supprime. + ## 11. Sécurité, dimensionnement, dépannage diff --git a/docs/MANUAL.hi.md b/docs/MANUAL.hi.md index cc68443..86cbc75 100644 --- a/docs/MANUAL.hi.md +++ b/docs/MANUAL.hi.md @@ -499,6 +499,8 @@ installation number दिखता है; उसे लेखक को भे मिले उसे data directory में रखें। संपर्क: । +अपंजीकृत इंस्टॉलेशन में बाईं साइडबार में एक छोटा प्रायोजक बैनर दिखता है। आपका ब्राउज़र इसे GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) से लोड करता है, या GitHub तक न पहुँच पाने पर उस प्रति से जिसे traffic66 सर्वर ने वहीं से डाउनलोड करके `/sponsor` में रखा है; traffic66 आपके या इंस्टॉलेशन के बारे में कोई डेटा नहीं भेजता। पंजीकरण करने पर यह हट जाता है। + ## 11. सुरक्षा, क्षमता का अनुमान, समस्या निवारण diff --git a/docs/MANUAL.id.md b/docs/MANUAL.id.md index f81f8bc..65289fb 100644 --- a/docs/MANUAL.id.md +++ b/docs/MANUAL.id.md @@ -508,6 +508,8 @@ setiap halaman menampilkan nomor instalasi 8 digit; kirim nomor itu kepada pembuatnya, lalu letakkan `license.json` yang Anda terima di direktori data. Kontak: . +Instalasi yang belum terdaftar menampilkan banner sponsor kecil di bilah sisi kiri. Browser Anda memuatnya dari GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)), atau bila GitHub tidak terjangkau, dari salinan yang diunduh server traffic66 dari sana dan disimpan di `/sponsor`; traffic66 tidak mengirim data apa pun tentang Anda atau instalasi. Mendaftar akan menghapusnya. + ## 11. Keamanan, kebutuhan sumber daya, pemecahan masalah diff --git a/docs/MANUAL.ja.md b/docs/MANUAL.ja.md index b960c38..2f6f27d 100644 --- a/docs/MANUAL.ja.md +++ b/docs/MANUAL.ja.md @@ -412,6 +412,8 @@ Linux では root か `setcap cap_net_raw,cap_net_admin+ep`、macOS では root [PolyForm Noncommercial License 1.0.0](../LICENSE.md) と [Traffic66 Additional Use Grant](../ADDITIONAL-USE-GRANT.md)(英語版が正本)のもとでのソースアベイラブルです。評価用途と 100 人未満の組織は無料。それより大きな組織は本番利用 30 日後に登録が必要です。販売、他者向けのホスティング、競合製品には商用ライセンスが必要です。機能が止まることは一切ありません。各ページの下部に 8 桁のインストール番号が表示されるので、それを作者に送り、返ってきた `license.json` をデータディレクトリに置いてください。連絡先:。 +未登録のインストールでは、左サイドバーに小さなスポンサーバナーが表示されます。ブラウザーが GitHub([ads-sponsor](https://github.com/githubflyideas/ads-sponsor))から読み込みます。GitHub に届かない場合は、traffic66 サーバーがそこからダウンロードして `/sponsor` に保存したコピーを使います。traffic66 はあなたやインストールに関するデータを一切送信しません。登録すると表示されなくなります。 + ## 11. セキュリティ、サイジング、トラブルシューティング diff --git a/docs/MANUAL.ko.md b/docs/MANUAL.ko.md index 8197e52..8847b54 100644 --- a/docs/MANUAL.ko.md +++ b/docs/MANUAL.ko.md @@ -412,6 +412,8 @@ Linux는 root 또는 `setcap cap_net_raw,cap_net_admin+ep`, macOS는 root, Windo [PolyForm Noncommercial License 1.0.0](../LICENSE.md)과 [Traffic66 Additional Use Grant](../ADDITIONAL-USE-GRANT.md)(영문이 구속력을 가짐)에 따른 소스 공개: 평가 용도와 100명 미만 조직은 무료이고, 더 큰 조직은 운영 사용 30일 뒤 등록합니다. 판매, 타인을 위한 호스팅, 경쟁 제품에는 상용 라이선스가 필요합니다. 꺼지는 기능은 전혀 없습니다. 모든 페이지 하단에 8자리 설치 번호가 표시되니 작성자에게 보내고, 돌려받은 `license.json`을 데이터 디렉터리에 넣으십시오. 연락처: . +등록되지 않은 설치에서는 왼쪽 사이드바에 작은 후원 배너가 표시됩니다. 브라우저가 GitHub([ads-sponsor](https://github.com/githubflyideas/ads-sponsor))에서 불러오고, GitHub에 닿지 않으면 traffic66 서버가 그곳에서 내려받아 `/sponsor`에 보관한 사본을 사용합니다. traffic66은 사용자나 설치에 관한 어떤 데이터도 보내지 않습니다. 등록하면 사라집니다. + ## 11. 보안, 용량 산정, 문제 해결 diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 4341c5d..bfe224d 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -540,6 +540,8 @@ of each page shows the 8-digit installation number; send it to the author, and put the `license.json` you get back in the data directory. Contact: . +Unregistered installations show a small sponsor banner in the left sidebar. Your browser loads it from GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)), or, when it cannot reach GitHub, from a copy the traffic66 server downloaded from there and keeps in `/sponsor`; traffic66 sends no data about you or the installation. Registering removes it. + ## 11. Security, sizing, troubleshooting The web UI is plain HTTP unless it is told otherwise. It can serve HTTPS diff --git a/docs/MANUAL.pt.md b/docs/MANUAL.pt.md index f3e3f58..0bdf6b8 100644 --- a/docs/MANUAL.pt.md +++ b/docs/MANUAL.pt.md @@ -507,6 +507,8 @@ mostra o número de instalação de 8 dígitos; envie-o ao autor e coloque o `license.json` que receber de volta no diretório de dados. Contato: . +Instalações não registradas mostram um pequeno banner de patrocínio na barra lateral esquerda. O seu navegador o carrega do GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) ou, se não conseguir alcançá-lo, de uma cópia que o servidor traffic66 baixou de lá e guarda em `/sponsor`; o traffic66 não envia nenhum dado sobre você ou a instalação. O registro o remove. + ## 11. Segurança, dimensionamento, solução de problemas diff --git a/docs/MANUAL.ru.md b/docs/MANUAL.ru.md index b106d63..494dcf6 100644 --- a/docs/MANUAL.ru.md +++ b/docs/MANUAL.ru.md @@ -507,6 +507,8 @@ traffic66 и скопируйте каталог; обновление: заме отправьте его автору и положите полученный `license.json` в каталог данных. Контакт: . +В незарегистрированных установках в левой боковой панели показывается небольшой спонсорский баннер. Браузер загружает его с GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)), а если GitHub недоступен — из копии, которую сервер traffic66 скачал оттуда и хранит в `/sponsor`; traffic66 не отправляет никаких данных о вас или об установке. После регистрации баннер исчезает. + ## 11. Безопасность, оценка ресурсов, устранение неполадок diff --git a/docs/MANUAL.ur.md b/docs/MANUAL.ur.md index 18cc921..b37719e 100644 --- a/docs/MANUAL.ur.md +++ b/docs/MANUAL.ur.md @@ -500,6 +500,8 @@ available (انگریزی متن لازم ہے): جانچ کے لیے اور 100 مصنف کو بھیجیں، اور جو `license.json` واپس ملے اسے data directory میں رکھیں۔ رابطہ: ۔ +غیر رجسٹرڈ انسٹالیشنز میں بائیں سائیڈبار میں ایک چھوٹا سپانسر بینر دکھائی دیتا ہے۔ آپ کا براؤزر اسے GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) سے لوڈ کرتا ہے، یا GitHub تک رسائی نہ ہو تو اس نقل سے جو traffic66 سرور نے وہیں سے ڈاؤن لوڈ کر کے `/sponsor` میں رکھی ہے؛ traffic66 آپ یا انسٹالیشن کے بارے میں کوئی ڈیٹا نہیں بھیجتا۔ رجسٹر کرنے پر یہ ہٹ جاتا ہے۔ + ## 11. سکیورٹی، گنجائش کا اندازہ، مسائل کا حل diff --git a/docs/MANUAL.vi.md b/docs/MANUAL.vi.md index c39f083..19b05b5 100644 --- a/docs/MANUAL.vi.md +++ b/docs/MANUAL.vi.md @@ -501,6 +501,8 @@ có tính năng nào bị khóa. Cuối mỗi trang hiển thị mã cài đặt này cho tác giả và đặt file `license.json` nhận được vào thư mục dữ liệu. Liên hệ: . +Bản cài đặt chưa đăng ký hiển thị một banner tài trợ nhỏ ở thanh bên trái. Trình duyệt của bạn tải nó từ GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)), hoặc khi không truy cập được GitHub thì từ bản sao mà máy chủ traffic66 đã tải về và lưu trong `/sponsor`; traffic66 không gửi bất kỳ dữ liệu nào về bạn hay bản cài đặt. Đăng ký sẽ gỡ bỏ nó. + ## 11. Bảo mật, định cỡ, xử lý sự cố diff --git a/docs/MANUAL.zh.md b/docs/MANUAL.zh.md index dd251b4..26a6d65 100644 --- a/docs/MANUAL.zh.md +++ b/docs/MANUAL.zh.md @@ -412,6 +412,8 @@ Linux 需要 root 或 `setcap cap_net_raw,cap_net_admin+ep`,macOS 需要 root 以 [PolyForm Noncommercial License 1.0.0](../LICENSE.md) 和 [Traffic66 Additional Use Grant](../ADDITIONAL-USE-GRANT.md) 公开源码(以英文文本为准):评估免费,少于 100 人的组织免费;更大的组织在生产使用 30 天后需注册;销售、为他人托管或提供竞争性产品需要商业许可。任何功能都不会被关闭。每个页面底部显示 8 位安装编号;把它发给作者,并把收到的 `license.json` 放进数据目录。联系方式:。 +未注册的安装会在左侧栏显示一个小的赞助横幅。它由你的浏览器从 GitHub([ads-sponsor](https://github.com/githubflyideas/ads-sponsor))加载;浏览器访问不了 GitHub 时,改用 traffic66 服务器从那里下载并缓存在 `/sponsor` 的副本。traffic66 不会发送任何关于你或本安装的数据。注册后横幅即消失。 + ## 11. 安全、容量规划、故障排查 diff --git a/internal/api/server.go b/internal/api/server.go index e25db7c..37ccc3f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -184,6 +184,8 @@ func (s *Server) Handler() http.Handler { adminAPI("DELETE /api/sandbox/files", s.deleteSandbox) api("POST /api/sandbox/active", s.putSandboxActive) adminAPI("POST /api/license", s.putLicense) + api("GET /api/sponsor", s.getSponsor) + api("GET /api/sponsor/img/{name}", s.getSponsorImg) adminAPI("POST /api/logo", s.putLogo) adminAPI("DELETE /api/logo", s.deleteLogo) api("GET /api/geo", s.getGeo) diff --git a/internal/api/sponsor.go b/internal/api/sponsor.go new file mode 100644 index 0000000..9be88e8 --- /dev/null +++ b/internal/api/sponsor.go @@ -0,0 +1,272 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// The sponsor banner of unregistered installations. The browser first loads +// it straight from GitHub; when it cannot (intranet, proxy, blocked), the +// page asks this server, which keeps the last set it managed to download in +// /sponsor and serves that. The download is a plain GET with no data +// about the user or the installation, and is skipped while registered. + +var ( + sponsorBase = "https://raw.githubusercontent.com/githubflyideas/ads-sponsor/main/traffic66/" + sponsorEvery = 6 * time.Hour + sponsorTimeout = 10 * time.Second + sponsorClient = &http.Client{Timeout: sponsorTimeout} + sponsorImgTypes = map[string]string{"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif"} + sponsorHTTPS = true // images over https only; tests use plain http + sponsorName = regexp.MustCompile(`^[0-3]\.(png|jpg|webp|gif)$`) +) + +const ( + sponsorMaxSlides = 4 + sponsorMaxImage = 150 << 10 + sponsorMaxManifest = 64 << 10 +) + +// SponsorSlide is one slide as kept on disk and served to the page. +type SponsorSlide struct { + ID string `json:"id,omitempty"` + Image string `json:"image"` + Link string `json:"link,omitempty"` + Alt string `json:"alt"` + Title string `json:"title,omitempty"` +} + +type sponsorManifest struct { + Version int `json:"version"` + Updated string `json:"updated,omitempty"` + Slides []SponsorSlide `json:"slides"` +} + +func clip(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) > n { + s = s[:n] + } + return s +} + +func httpLink(v string, base *url.URL, httpsOnly bool) string { + u, err := url.Parse(strings.TrimSpace(v)) + if err != nil || v == "" { + return "" + } + if base != nil { + u = base.ResolveReference(u) + } + if u.Scheme == "https" || (!httpsOnly && u.Scheme == "http") { + if u.Host != "" { + return u.String() + } + } + return "" +} + +// validSponsor checks a downloaded manifest: at most four slides, https +// images, http or https links (others dropped, the slide not clickable). +func validSponsor(b []byte, base string) ([]SponsorSlide, error) { + var m sponsorManifest + if err := json.Unmarshal(b, &m); err != nil { + return nil, fmt.Errorf("sponsor: manifest: %w", err) + } + bu, _ := url.Parse(base) + var out []SponsorSlide + for _, s := range m.Slides { + if len(out) == sponsorMaxSlides { + break + } + img := httpLink(s.Image, bu, sponsorHTTPS) + if img == "" { + continue + } + out = append(out, SponsorSlide{ID: clip(s.ID, 60), Image: img, Link: httpLink(s.Link, nil, false), + Alt: clip(s.Alt, 120), Title: clip(s.Title, 80)}) + } + if len(out) == 0 { + return nil, errors.New("sponsor: no usable slides") + } + return out, nil +} + +// sponsorImage checks one downloaded image and returns its file extension. +func sponsorImage(b []byte) (string, error) { + if len(b) == 0 || len(b) > sponsorMaxImage { + return "", fmt.Errorf("sponsor: image of %d bytes", len(b)) + } + ext, ok := sponsorImgTypes[http.DetectContentType(b)] + if !ok { + return "", fmt.Errorf("sponsor: image type %s", http.DetectContentType(b)) + } + return ext, nil +} + +func sponsorGet(ctx context.Context, u string, max int64, ua string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", ua) + resp, err := sponsorClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %s", u, resp.Status) + } + b, err := io.ReadAll(io.LimitReader(resp.Body, max+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > max { + return nil, fmt.Errorf("%s: larger than %d bytes", u, max) + } + return b, nil +} + +// refreshSponsor downloads the manifest and its images into dir. The set on +// disk is replaced only by a complete new one with at least one image, so +// the last good set stays when GitHub cannot be reached. +func refreshSponsor(ctx context.Context, dir, ua string) error { + b, err := sponsorGet(ctx, sponsorBase+"sponsors.json", sponsorMaxManifest, ua) + if err != nil { + return err + } + slides, err := validSponsor(b, sponsorBase+"sponsors.json") + if err != nil { + return err + } + tmp := dir + ".new" + os.RemoveAll(tmp) + if err := os.MkdirAll(tmp, 0o755); err != nil { + return err + } + defer os.RemoveAll(tmp) + var keep []SponsorSlide + for _, s := range slides { + ib, err := sponsorGet(ctx, s.Image, sponsorMaxImage, ua) + if err != nil { + continue + } + ext, err := sponsorImage(ib) + if err != nil { + continue + } + name := fmt.Sprintf("%d%s", len(keep), ext) + if err := os.WriteFile(filepath.Join(tmp, name), ib, 0o644); err != nil { + return err + } + s.Image = name + keep = append(keep, s) + } + if len(keep) == 0 { + return errors.New("sponsor: no image could be downloaded") + } + mb, _ := json.Marshal(sponsorManifest{Version: 1, Updated: time.Now().UTC().Format(time.RFC3339), Slides: keep}) + if err := os.WriteFile(filepath.Join(tmp, "sponsors.json"), mb, 0o644); err != nil { + return err + } + old := dir + ".old" + os.RemoveAll(old) + if _, err := os.Stat(dir); err == nil { + if err := os.Rename(dir, old); err != nil { + return err + } + } + if err := os.Rename(tmp, dir); err != nil { + os.Rename(old, dir) + return err + } + os.RemoveAll(old) + return nil +} + +func (s *Server) sponsorDir() string { return filepath.Join(s.DataDir, "sponsor") } + +func (s *Server) registered() bool { return s.License != nil && s.License.State().Registered() } + +// RunSponsor keeps the sponsor cache fresh: at start and every six hours, +// and only while the installation is not registered. +func (s *Server) RunSponsor(ctx context.Context) { + if s.DataDir == "" { + return + } + ua := "traffic66/" + s.Version + for { + if !s.registered() { + c, cancel := context.WithTimeout(ctx, time.Minute) + if err := refreshSponsor(c, s.sponsorDir(), ua); err != nil { + log.Printf("sponsor: %v (keeping the cached banner, if any)", err) + } + cancel() + } + select { + case <-ctx.Done(): + return + case <-time.After(sponsorEvery): + } + } +} + +// getSponsor serves the cached manifest with the images pointing here. +func (s *Server) getSponsor(w http.ResponseWriter, r *http.Request) { + if s.registered() || s.DataDir == "" { + http.NotFound(w, r) + return + } + b, err := os.ReadFile(filepath.Join(s.sponsorDir(), "sponsors.json")) + if err != nil { + http.NotFound(w, r) + return + } + var m sponsorManifest + if json.Unmarshal(b, &m) != nil { + http.NotFound(w, r) + return + } + var out []SponsorSlide + for _, sl := range m.Slides { + if sponsorName.MatchString(sl.Image) && len(out) < sponsorMaxSlides { + sl.Image = "/api/sponsor/img/" + sl.Image + out = append(out, sl) + } + } + writeJSON(w, http.StatusOK, map[string]any{"slides": out}) +} + +func (s *Server) getSponsorImg(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if s.registered() || s.DataDir == "" || !sponsorName.MatchString(name) { + http.NotFound(w, r) + return + } + b, err := os.ReadFile(filepath.Join(s.sponsorDir(), name)) + if err != nil { + http.NotFound(w, r) + return + } + ext, err := sponsorImage(b) + if err != nil || ext != filepath.Ext(name) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", http.DetectContentType(b)) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "private, max-age=3600") + w.Write(b) +} diff --git a/internal/api/sponsor_test.go b/internal/api/sponsor_test.go new file mode 100644 index 0000000..48e4dfc --- /dev/null +++ b/internal/api/sponsor_test.go @@ -0,0 +1,141 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "image" + "image/png" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func tinyPNG(t *testing.T) []byte { + var b bytes.Buffer + if err := png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 8, 1))); err != nil { + t.Fatal(err) + } + return b.Bytes() +} + +func TestValidSponsor(t *testing.T) { + base := "https://example.com/t/sponsors.json" + in := `{"slides":[ + {"image":"a.png","link":"https://a.example/?ref=1","alt":"A"}, + {"image":"http://insecure/b.png","link":"https://b","alt":"B"}, + {"image":"c.png","link":"mailto:x@y","alt":"C"}, + {"image":"d.png","link":"javascript:alert(1)","alt":"D"}, + {"image":"e.png","alt":"E"}, + {"image":"f.png","alt":"F"}]}` + s, err := validSponsor([]byte(in), base) + if err != nil { + t.Fatal(err) + } + if len(s) != 4 { + t.Fatalf("want 4 slides, got %d", len(s)) + } + if s[0].Image != "https://example.com/t/a.png" || s[0].Link != "https://a.example/?ref=1" { + t.Fatalf("slide 0: %+v", s[0]) + } + if s[1].Alt != "C" || s[1].Link != "" || s[2].Link != "" { + t.Fatalf("bad links kept: %+v", s) + } + if _, err := validSponsor([]byte(`{"slides":[]}`), base); err == nil { + t.Fatal("empty manifest accepted") + } + if _, err := validSponsor([]byte(`nope`), base); err == nil { + t.Fatal("bad json accepted") + } +} + +func TestSponsorImage(t *testing.T) { + if ext, err := sponsorImage(tinyPNG(t)); err != nil || ext != ".png" { + t.Fatalf("png: %q %v", ext, err) + } + if _, err := sponsorImage([]byte("")); err == nil { + t.Fatal("svg accepted") + } + big := append(tinyPNG(t), make([]byte, sponsorMaxImage)...) + if _, err := sponsorImage(big); err == nil { + t.Fatal("oversized image accepted") + } +} + +func TestSponsorCacheFallback(t *testing.T) { + pic := tinyPNG(t) + up := true + var ua string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ua = r.UserAgent() + if !up { + http.Error(w, "down", 503) + return + } + switch r.URL.Path { + case "/t/sponsors.json": + w.Write([]byte(`{"slides":[{"image":"a.png","link":"https://a.example/","alt":"A"},{"image":"missing.png","alt":"M"}]}`)) + case "/t/a.png": + w.Write(pic) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + old := sponsorBase + sponsorBase = ts.URL + "/t/" + sponsorHTTPS = false + defer func() { sponsorBase, sponsorHTTPS = old, true }() + + data := t.TempDir() + s := &Server{DataDir: data, Version: "9.9.9"} + dir := s.sponsorDir() + if err := refreshSponsor(context.Background(), dir, "traffic66/9.9.9"); err != nil { + t.Fatal(err) + } + if ua != "traffic66/9.9.9" { + t.Fatalf("user agent %q", ua) + } + // GitHub goes away: the last good set stays + up = false + if err := refreshSponsor(context.Background(), dir, "x"); err == nil { + t.Fatal("refresh should fail while offline") + } + w := httptest.NewRecorder() + s.getSponsor(w, httptest.NewRequest("GET", "/api/sponsor", nil)) + var got struct{ Slides []SponsorSlide } + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || len(got.Slides) != 1 { + t.Fatalf("cached manifest: %s %v", w.Body, err) + } + if got.Slides[0].Image != "/api/sponsor/img/0.png" || got.Slides[0].Link != "https://a.example/" { + t.Fatalf("slide: %+v", got.Slides[0]) + } + r := httptest.NewRequest("GET", "/api/sponsor/img/0.png", nil) + r.SetPathValue("name", "0.png") + w = httptest.NewRecorder() + s.getSponsorImg(w, r) + if w.Code != 200 || !bytes.Equal(w.Body.Bytes(), pic) || w.Header().Get("Content-Type") != "image/png" { + t.Fatalf("image: %d %q", w.Code, w.Header().Get("Content-Type")) + } + for _, bad := range []string{"../license.json", "sponsors.json", "9.png"} { + r := httptest.NewRequest("GET", "/x", nil) + r.SetPathValue("name", bad) + w := httptest.NewRecorder() + s.getSponsorImg(w, r) + if w.Code != 404 { + t.Fatalf("%s served: %d", bad, w.Code) + } + } + // nothing cached at all: 404, the page shows its built-in text + os.RemoveAll(dir) + w = httptest.NewRecorder() + s.getSponsor(w, httptest.NewRequest("GET", "/api/sponsor", nil)) + if w.Code != 404 { + t.Fatalf("empty cache: %d", w.Code) + } + if _, err := os.Stat(filepath.Join(data, "sponsor.new")); !os.IsNotExist(err) { + t.Fatal("temporary directory left behind") + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 31c8eb5..e0378f3 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -95,6 +95,18 @@ nav.dash button[aria-current="page"] .ni{opacity:1} .self .live i{width:8px;height:8px;border-radius:50%;background:currentColor} .self .live.ok{color:var(--good)}.self .live.idle{color:var(--ink-3)} .foot{display:flex;flex-direction:column;gap:8px;padding:0 16px 14px} +.sponsor{display:flex;flex-direction:column;gap:4px} +.sponsor[hidden]{display:none} +.sponsor .sp-h{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-3)} +.sponsor .sp-text{border:1px dashed var(--line-2);border-radius:6px;padding:6px 8px;font-size:11.5px;line-height:1.35;color:var(--ink-3);overflow-wrap:anywhere} +.sponsor .sp-text[hidden],.spc[hidden]{display:none} +.spc-track{position:relative;aspect-ratio:728/90;width:100%;border-radius:6px;overflow:hidden;background:var(--surface);border:1px solid var(--line-2)} +.spc-slide{position:absolute;inset:0;opacity:0;transition:opacity .45s ease;pointer-events:none;display:block} +.spc-slide.on{opacity:1;pointer-events:auto} +.spc-slide img{width:100%;height:100%;object-fit:cover;display:block} +.spc-dots{display:flex;gap:6px;justify-content:center} +.spc-dot{width:6px;height:6px;padding:0;border-radius:50%;border:0;cursor:pointer;background:var(--line-2)} +.spc-dot.on{background:var(--accent)} .foot .pick{display:grid;grid-template-columns:1fr auto;gap:6px} #theme{display:flex;align-items:center;gap:6px;border:1px solid var(--line-2);border-radius:6px;padding:4px 10px;background:var(--surface);color:var(--ink);cursor:pointer;font-size:13px;white-space:nowrap} #theme i{width:12px;height:12px;border-radius:50%;background:var(--accent);box-shadow:inset 0 0 0 2px var(--surface),0 0 0 1px var(--line-2)} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index ffb727e..5ee270b 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -2975,6 +2975,8 @@ async function loadStatus() { waiting = s.waiting || null; setup = s; const L = lic = s.license || null; + // sponsor banner: unregistered only, fetched by this browser from GitHub + if (window.t66Sponsor) { if (L && L.registered) t66Sponsor.remove(); else t66Sponsor.load(); } if (L) { const id = `${esc(t('lic.id', {id: L.installation_id}))}`; let head; diff --git a/internal/web/static/i18n/ar.json b/internal/web/static/i18n/ar.json index d1dd962..ddcf6b9 100644 --- a/internal/web/static/i18n/ar.json +++ b/internal/web/static/i18n/ar.json @@ -648,6 +648,8 @@ "rec.live": "يُحدَّث كل 5 ثوانٍ", "rec.go_live": "العودة إلى البث المباشر", "theme.label": "السمة", +"sponsor.label": "راعٍ", +"sponsor.default": "هذه المساحة مخصصة لراعٍ واحد لخدمات VPS / IDC / الشبكات — للتواصل route66traffic66@gmail.com", "theme.light": "فاتح", "base.day": "أمس في الوقت نفسه", "base.week": "الأسبوع الماضي في الوقت نفسه", diff --git a/internal/web/static/i18n/bn.json b/internal/web/static/i18n/bn.json index 98a9a11..a58d303 100644 --- a/internal/web/static/i18n/bn.json +++ b/internal/web/static/i18n/bn.json @@ -648,6 +648,8 @@ "rec.live": "প্রতি ৫ সেকেন্ডে হালনাগাদ", "rec.go_live": "লাইভে ফিরুন", "theme.label": "থিম", +"sponsor.label": "স্পনসর", +"sponsor.default": "এই স্থানটি একটি VPS / IDC / নেটওয়ার্ক স্পনসরের জন্য — যোগাযোগ route66traffic66@gmail.com", "theme.light": "হালকা", "base.day": "গতকাল একই সময়", "base.week": "গত সপ্তাহে একই সময়", diff --git a/internal/web/static/i18n/de.json b/internal/web/static/i18n/de.json index 098f0a0..8122a3d 100644 --- a/internal/web/static/i18n/de.json +++ b/internal/web/static/i18n/de.json @@ -648,6 +648,8 @@ "rec.live": "Aktualisierung alle 5 s", "rec.go_live": "Zurück zu Live", "theme.label": "Design", +"sponsor.label": "Sponsor", +"sponsor.default": "Dieser Platz ist für einen VPS- / IDC- / Netzwerk-Sponsor — Kontakt route66traffic66@gmail.com", "theme.light": "Hell", "base.day": "Gleiche Zeit gestern", "base.week": "Gleiche Zeit letzte Woche", diff --git a/internal/web/static/i18n/en.json b/internal/web/static/i18n/en.json index cca77af..0da0d59 100644 --- a/internal/web/static/i18n/en.json +++ b/internal/web/static/i18n/en.json @@ -648,6 +648,8 @@ "rec.live": "updated every 5 s", "rec.go_live": "Back to live", "theme.label": "Theme", +"sponsor.label": "Sponsor", +"sponsor.default": "This slot is for one VPS / IDC / network sponsor — contact route66traffic66@gmail.com", "theme.light": "Light", "base.day": "Same time yesterday", "base.week": "Same time last week", diff --git a/internal/web/static/i18n/es.json b/internal/web/static/i18n/es.json index 1f26607..27a4060 100644 --- a/internal/web/static/i18n/es.json +++ b/internal/web/static/i18n/es.json @@ -648,6 +648,8 @@ "rec.live": "se actualiza cada 5 s", "rec.go_live": "Volver a tiempo real", "theme.label": "Tema", +"sponsor.label": "Patrocinador", +"sponsor.default": "Este espacio es para un patrocinador de VPS / IDC / redes — contacto route66traffic66@gmail.com", "theme.light": "Claro", "base.day": "Ayer a la misma hora", "base.week": "La semana pasada a la misma hora", diff --git a/internal/web/static/i18n/fr.json b/internal/web/static/i18n/fr.json index 30409a5..b586a99 100644 --- a/internal/web/static/i18n/fr.json +++ b/internal/web/static/i18n/fr.json @@ -648,6 +648,8 @@ "rec.live": "mis à jour toutes les 5 s", "rec.go_live": "Revenir au temps réel", "theme.label": "Thème", +"sponsor.label": "Sponsor", +"sponsor.default": "Cet emplacement est réservé à un sponsor VPS / IDC / réseau — contact route66traffic66@gmail.com", "theme.light": "Clair", "base.day": "Hier à la même heure", "base.week": "La semaine dernière à la même heure", diff --git a/internal/web/static/i18n/hi.json b/internal/web/static/i18n/hi.json index 28c8fac..7baacfe 100644 --- a/internal/web/static/i18n/hi.json +++ b/internal/web/static/i18n/hi.json @@ -648,6 +648,8 @@ "rec.live": "हर 5 सेकंड अपडेट", "rec.go_live": "लाइव पर लौटें", "theme.label": "थीम", +"sponsor.label": "प्रायोजक", +"sponsor.default": "यह स्थान एक VPS / IDC / नेटवर्क प्रायोजक के लिए है — संपर्क route66traffic66@gmail.com", "theme.light": "हल्का", "base.day": "कल इसी समय", "base.week": "पिछले सप्ताह इसी समय", diff --git a/internal/web/static/i18n/id.json b/internal/web/static/i18n/id.json index 562a729..82d6e99 100644 --- a/internal/web/static/i18n/id.json +++ b/internal/web/static/i18n/id.json @@ -648,6 +648,8 @@ "rec.live": "diperbarui tiap 5 dtk", "rec.go_live": "Kembali ke waktu nyata", "theme.label": "Tema", +"sponsor.label": "Sponsor", +"sponsor.default": "Slot ini untuk satu sponsor VPS / IDC / jaringan — hubungi route66traffic66@gmail.com", "theme.light": "Terang", "base.day": "Kemarin di jam yang sama", "base.week": "Minggu lalu di jam yang sama", diff --git a/internal/web/static/i18n/ja.json b/internal/web/static/i18n/ja.json index fa5b3a4..d6219d4 100644 --- a/internal/web/static/i18n/ja.json +++ b/internal/web/static/i18n/ja.json @@ -648,6 +648,8 @@ "rec.live": "5 秒ごとに更新", "rec.go_live": "リアルタイムに戻る", "theme.label": "テーマ", +"sponsor.label": "スポンサー", +"sponsor.default": "この枠は VPS / IDC / ネットワークのスポンサー 1 社向けです — 連絡先 route66traffic66@gmail.com", "theme.light": "ライト", "base.day": "昨日の同時刻", "base.week": "先週の同時刻", diff --git a/internal/web/static/i18n/ko.json b/internal/web/static/i18n/ko.json index c611e47..8196dac 100644 --- a/internal/web/static/i18n/ko.json +++ b/internal/web/static/i18n/ko.json @@ -648,6 +648,8 @@ "rec.live": "5초마다 업데이트", "rec.go_live": "실시간으로 돌아가기", "theme.label": "테마", +"sponsor.label": "후원", +"sponsor.default": "이 자리는 VPS / IDC / 네트워크 후원사 한 곳을 위한 공간입니다 — 연락처 route66traffic66@gmail.com", "theme.light": "라이트", "base.day": "어제 같은 시간", "base.week": "지난주 같은 시간", diff --git a/internal/web/static/i18n/pt.json b/internal/web/static/i18n/pt.json index eb7fbeb..db251c8 100644 --- a/internal/web/static/i18n/pt.json +++ b/internal/web/static/i18n/pt.json @@ -648,6 +648,8 @@ "rec.live": "atualizado a cada 5 s", "rec.go_live": "Voltar ao tempo real", "theme.label": "Tema", +"sponsor.label": "Patrocinador", +"sponsor.default": "Este espaço é para um patrocinador de VPS / IDC / rede — contato route66traffic66@gmail.com", "theme.light": "Claro", "base.day": "Ontem no mesmo horário", "base.week": "Semana passada no mesmo horário", diff --git a/internal/web/static/i18n/ru.json b/internal/web/static/i18n/ru.json index 744cb3e..baaf41e 100644 --- a/internal/web/static/i18n/ru.json +++ b/internal/web/static/i18n/ru.json @@ -648,6 +648,8 @@ "rec.live": "обновляется каждые 5 с", "rec.go_live": "Вернуться к реальному времени", "theme.label": "Тема", +"sponsor.label": "Спонсор", +"sponsor.default": "Это место для одного спонсора VPS / IDC / сетей — пишите на route66traffic66@gmail.com", "theme.light": "Светлая", "base.day": "Вчера в то же время", "base.week": "Неделю назад в то же время", diff --git a/internal/web/static/i18n/ur.json b/internal/web/static/i18n/ur.json index 089c80d..05312f9 100644 --- a/internal/web/static/i18n/ur.json +++ b/internal/web/static/i18n/ur.json @@ -648,6 +648,8 @@ "rec.live": "ہر 5 سیکنڈ میں تازہ", "rec.go_live": "لائیو پر واپس", "theme.label": "تھیم", +"sponsor.label": "سپانسر", +"sponsor.default": "یہ جگہ ایک VPS / IDC / نیٹ ورک سپانسر کے لیے ہے — رابطہ route66traffic66@gmail.com", "theme.light": "ہلکا", "base.day": "کل اسی وقت", "base.week": "پچھلے ہفتے اسی وقت", diff --git a/internal/web/static/i18n/vi.json b/internal/web/static/i18n/vi.json index 69c3327..52366fe 100644 --- a/internal/web/static/i18n/vi.json +++ b/internal/web/static/i18n/vi.json @@ -648,6 +648,8 @@ "rec.live": "cập nhật mỗi 5 s", "rec.go_live": "Quay lại trực tiếp", "theme.label": "Giao diện màu", +"sponsor.label": "Nhà tài trợ", +"sponsor.default": "Vị trí này dành cho một nhà tài trợ VPS / IDC / mạng — liên hệ route66traffic66@gmail.com", "theme.light": "Sáng", "base.day": "Cùng giờ hôm qua", "base.week": "Cùng giờ tuần trước", diff --git a/internal/web/static/i18n/zh.json b/internal/web/static/i18n/zh.json index 648b6d0..4ed817f 100644 --- a/internal/web/static/i18n/zh.json +++ b/internal/web/static/i18n/zh.json @@ -648,6 +648,8 @@ "rec.live": "每 5 秒更新", "rec.go_live": "回到实时", "theme.label": "主题", +"sponsor.label": "赞助", +"sponsor.default": "此位置留给一家 VPS / IDC / 网络赞助商 — 联系 route66traffic66@gmail.com", "theme.light": "浅色", "base.day": "昨天同时段", "base.week": "上周同期", diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 1535f17..ffae465 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -46,6 +46,7 @@
+
@@ -106,6 +107,7 @@

+ diff --git a/internal/web/static/sponsor.js b/internal/web/static/sponsor.js new file mode 100644 index 0000000..a2a23b8 --- /dev/null +++ b/internal/web/static/sponsor.js @@ -0,0 +1,167 @@ +// Remote sponsor banner for unregistered installations. +// +// The slides are listed in a manifest in +// https://github.com/githubflyideas/ads-sponsor (folder traffic66/), so they +// can change without a release. The browser fetches it directly; the +// traffic66 server never contacts GitHub, and no user or installation data is +// sent. Order of fallbacks: +// 1. the manifest and images straight from GitHub (this browser); +// 2. the last set the traffic66 server downloaded from GitHub (/api/sponsor), +// for browsers that cannot reach GitHub; +// 3. a built-in text box. A broken image is never shown. +// Registered installations never call this. +// +// Usage: t66Sponsor.load() t66Sponsor.remove() +(function () { + 'use strict'; + var URL_ = 'https://raw.githubusercontent.com/githubflyideas/ads-sponsor/main/traffic66/sponsors.json'; + var TIMEOUT_MS = 3000, ROTATE_MS = 5500, MAX = 4; + var CACHE_KEY = 't66.sponsor', CACHE_MS = 30 * 60 * 1000; + var started = false, timerId = null; + + function httpURL(v, base, httpsOnly) { + if (typeof v !== 'string' || !v.trim()) return null; + try { + var u = new URL(v.trim(), base); + if (u.protocol === 'https:' || (!httpsOnly && u.protocol === 'http:')) return u; + } catch (e) {} + return null; + } + function str(v, n) { return typeof v === 'string' ? v.trim().slice(0, n) : ''; } + function slug(v) { + return String(v || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40); + } + // keeps the sponsor's own parameters (ref, affiliate ids, utm_*) and only + // adds the utm_* ones that are not there yet + function tag(u, campaign) { + var p = u.searchParams, add = {utm_source: 'traffic66', utm_medium: 'sponsor', utm_campaign: campaign}; + Object.keys(add).forEach(function (k) { if (add[k] && !p.has(k)) p.set(k, add[k]); }); + return u.href; + } + + function loadImg(src) { + return new Promise(function (ok, bad) { + var img = new Image(); + img.decoding = 'async'; + img.referrerPolicy = 'no-referrer'; + img.onload = function () { img.naturalWidth > 0 ? ok(img) : bad(); }; + img.onerror = bad; + img.src = src; + }); + } + function settle(ps) { + return Promise.all(ps.map(function (p) { return p.then(function (v) { return v; }, function () { return null; }); })); + } + + function cached() { + try { + var c = JSON.parse(sessionStorage.getItem(CACHE_KEY) || 'null'); + if (c && c.at && Date.now() - c.at < CACHE_MS && c.m) return c.m; + } catch (e) {} + return null; + } + function getJSON(url, opt) { + if (!window.fetch) return Promise.reject(); + var ctl = window.AbortController ? new AbortController() : null; + var t = setTimeout(function () { if (ctl) ctl.abort(); }, TIMEOUT_MS); + opt.signal = ctl ? ctl.signal : undefined; + return fetch(url, opt) + .then(function (r) { if (!r.ok) throw 0; return r.json(); }) + .then(function (m) { clearTimeout(t); return m; }, function (e) { clearTimeout(t); throw e; }); + } + function remote() { + var c = cached(); + if (c) return Promise.resolve(c); + return getJSON(URL_, {credentials: 'omit', referrerPolicy: 'no-referrer', cache: 'no-cache', mode: 'cors'}) + .then(function (m) { + try { sessionStorage.setItem(CACHE_KEY, JSON.stringify({at: Date.now(), m: m})); } catch (e) {} + return m; + }); + } + function local() { + return getJSON('/api/sponsor', {credentials: 'same-origin', cache: 'no-cache'}); + } + // resolves with the slides whose images loaded, rejects when none did + function ready(m, base) { + var want = slidesOf(m, base); + if (!want.length) return Promise.reject(); + return settle(want.map(function (s) { return loadImg(s.src); })).then(function (imgs) { + var ok = []; + want.forEach(function (s, i) { if (imgs[i]) { s.img = imgs[i]; ok.push(s); } }); + if (!ok.length) throw 0; + return ok; + }); + } + + function slidesOf(m, base) { + var list = m && Array.isArray(m.slides) ? m.slides : [], out = []; + for (var i = 0; i < list.length && out.length < MAX; i++) { + var s = list[i] || {}, img = httpURL(s.image, base, base === URL_); + if (!img) continue; + var alt = str(s.alt, 120), id = slug(str(s.id, 60)) || slug(alt) || ('slide-' + (i + 1)); + var link = httpURL(s.link, base, false); + out.push({src: img.href, alt: alt, title: str(s.title, 80), link: link ? tag(link, id) : null}); + } + return out; + } + + function build(slot, slides) { + var car = slot.querySelector('.spc'); + car.hidden = false; + var track = document.createElement('div'); track.className = 'spc-track'; + var dots = document.createElement('div'); dots.className = 'spc-dots'; + var els = [], ds = [], cur = 0, paused = false; + function show(i) { + cur = i; + els.forEach(function (e, j) { e.classList.toggle('on', j === i); }); + ds.forEach(function (e, j) { e.classList.toggle('on', j === i); }); + } + slides.forEach(function (s, i) { + var a = document.createElement(s.link ? 'a' : 'div'); + a.className = 'spc-slide'; + if (s.link) { a.href = s.link; a.target = '_blank'; a.rel = 'noopener noreferrer sponsored'; } + if (s.title || s.alt) a.title = s.title || s.alt; + s.img.alt = s.alt; + a.appendChild(s.img); + track.appendChild(a); els.push(a); + if (slides.length > 1) { + var d = document.createElement('button'); + d.type = 'button'; d.className = 'spc-dot'; + d.setAttribute('aria-label', String(i + 1)); + d.onclick = function () { show(i); }; + dots.appendChild(d); ds.push(d); + } + }); + car.textContent = ''; + car.appendChild(track); + if (slides.length > 1) car.appendChild(dots); + show(0); + if (slides.length > 1) { + ['mouseenter', 'focusin'].forEach(function (e) { car.addEventListener(e, function () { paused = true; }); }); + ['mouseleave', 'focusout'].forEach(function (e) { car.addEventListener(e, function () { paused = false; }); }); + timerId = setInterval(function () { + if (!paused && !document.hidden) show((cur + 1) % slides.length); + }, ROTATE_MS); + } + slot.hidden = false; + } + + window.t66Sponsor = { + load: function () { + var slot = document.getElementById('sponsor'); + if (started || !slot || !window.URL || !window.Promise) return; + started = true; + var here = location.origin + '/api/sponsor'; + remote().then(function (m) { return ready(m, URL_); }) + .catch(function () { return local().then(function (m) { return ready(m, here); }); }) + .then(function (ok) { if (started) build(slot, ok); }, + function () { if (started) { slot.querySelector('.sp-text').hidden = false; slot.hidden = false; } }); + }, + remove: function () { + started = false; + if (timerId) clearInterval(timerId); + var slot = document.getElementById('sponsor'); + if (slot) slot.remove(); + } + }; +})();