trackguard 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/app/assets/javascripts/controllers/page_tracker_controller.js +41 -0
  3. data/app/assets/stylesheets/trackguard/admin.css +479 -0
  4. data/app/controllers/concerns/trackguard/page_tracker.rb +41 -0
  5. data/app/controllers/trackguard/admin/analytics_controller.rb +85 -0
  6. data/app/controllers/trackguard/admin/base_controller.rb +25 -0
  7. data/app/controllers/trackguard/admin/blocked_user_agents_controller.rb +27 -0
  8. data/app/controllers/trackguard/admin/dashboards_controller.rb +17 -0
  9. data/app/controllers/trackguard/admin/visitors_controller.rb +57 -0
  10. data/app/controllers/trackguard/admin/visits_controller.rb +17 -0
  11. data/app/controllers/trackguard/admin/whitelisted_ips_controller.rb +64 -0
  12. data/app/controllers/trackguard/page_views_controller.rb +18 -0
  13. data/app/helpers/trackguard/application_helper.rb +10 -0
  14. data/app/jobs/trackguard/detect_suspicious_visitors_job.rb +130 -0
  15. data/app/jobs/trackguard/track_page_view_job.rb +29 -0
  16. data/app/models/trackguard/blocked_user_agent.rb +16 -0
  17. data/app/models/trackguard/page_view.rb +17 -0
  18. data/app/models/trackguard/visitor.rb +24 -0
  19. data/app/models/trackguard/whitelisted_ip.rb +26 -0
  20. data/app/services/trackguard/application_service.rb +7 -0
  21. data/app/services/trackguard/page_view_recorder.rb +39 -0
  22. data/app/views/layouts/trackguard/admin.html.erb +68 -0
  23. data/app/views/trackguard/admin/dashboards/show.html.erb +234 -0
  24. data/app/views/trackguard/admin/visits/_pagination.html.erb +48 -0
  25. data/app/views/trackguard/admin/visits/index.html.erb +148 -0
  26. data/config/importmap.rb +1 -0
  27. data/config/routes.rb +14 -0
  28. data/lib/generators/trackguard/install_generator.rb +24 -0
  29. data/lib/generators/trackguard/templates/create_trackguard_tables.rb +48 -0
  30. data/lib/tasks/trackguard.rake +32 -0
  31. data/lib/trackguard/engine.rb +25 -0
  32. data/lib/trackguard/rack_attack.rb +31 -0
  33. data/lib/trackguard/version.rb +3 -0
  34. data/lib/trackguard.rb +40 -0
  35. data/trackguard.gemspec +18 -0
  36. metadata +102 -0
@@ -0,0 +1,17 @@
1
+ module Trackguard
2
+ module Admin
3
+ class DashboardsController < BaseController
4
+ def show
5
+ @total_today = PageView.today.count
6
+ @total_week = PageView.this_week.count
7
+ @total_month = PageView.this_month.count
8
+
9
+ @top_pages = PageView.last_30.group(:path).order("count_all DESC").limit(5).count
10
+ @top_referrers = PageView.last_30.with_referrer.group(:referer).order("count_all DESC").limit(5).count
11
+ @top_sources = PageView.last_30.with_source.group(:source).order("count_all DESC").limit(5).count
12
+
13
+ @recent = PageView.order(created_at: :desc).limit(20).includes(visitor: :whitelisted_ip)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,57 @@
1
+ module Trackguard
2
+ module Admin
3
+ class VisitorsController < BaseController
4
+ skip_before_action :verify_authenticity_token, if: :valid_api_token?
5
+ before_action :set_visitor
6
+
7
+ rescue_from ActiveRecord::RecordNotFound do
8
+ respond_to do |format|
9
+ format.html { redirect_to dashboard_path, alert: "Visitor not found." }
10
+ format.json { render json: { error: "Visitor not found" }, status: :not_found }
11
+ end
12
+ end
13
+
14
+ def flag
15
+ if @visitor.update(
16
+ flagged_at: Time.current,
17
+ flag_reason: params[:flag_reason].presence,
18
+ flagged_by: params[:flagged_by].presence || Visitor::FLAGGED_BY.first
19
+ )
20
+ respond_to do |format|
21
+ format.html { redirect_back_or_to dashboard_path }
22
+ format.json { render json: { status: "ok", ip: @visitor.ip, flagged_at: @visitor.flagged_at } }
23
+ end
24
+ else
25
+ respond_to do |format|
26
+ format.html { redirect_back_or_to dashboard_path, alert: @visitor.errors.full_messages.join(", ") }
27
+ format.json { render json: { errors: @visitor.errors.full_messages }, status: :unprocessable_entity }
28
+ end
29
+ end
30
+ end
31
+
32
+ def unflag
33
+ @visitor.update!(flagged_at: nil, flag_reason: nil, flagged_by: nil)
34
+ respond_to do |format|
35
+ format.html { redirect_back_or_to dashboard_path }
36
+ format.json { render json: { status: "ok", ip: @visitor.ip } }
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def authenticate_admin!
43
+ return if valid_api_token?
44
+
45
+ super
46
+ end
47
+
48
+ def set_visitor
49
+ @visitor = if params[:ip].present?
50
+ Visitor.find_by!(ip: params[:ip])
51
+ else
52
+ Visitor.find(params[:id])
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,17 @@
1
+ module Trackguard
2
+ module Admin
3
+ class VisitsController < BaseController
4
+ PER_PAGE = 20
5
+
6
+ def index
7
+ @page = [ (params[:page] || 1).to_i, 1 ].max
8
+ @total = PageView.count
9
+ @pages = (@total.to_f / PER_PAGE).ceil
10
+ @visits = PageView.order(created_at: :desc)
11
+ .limit(PER_PAGE)
12
+ .offset((@page - 1) * PER_PAGE)
13
+ .includes(visitor: :whitelisted_ip)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,64 @@
1
+ module Trackguard
2
+ module Admin
3
+ class WhitelistedIpsController < BaseController
4
+ skip_before_action :verify_authenticity_token, if: :valid_api_token?
5
+ before_action :set_visitor
6
+
7
+ rescue_from ActiveRecord::RecordNotFound do
8
+ respond_to do |format|
9
+ format.html { redirect_to dashboard_path, alert: "Visitor not found." }
10
+ format.json { render json: { error: "Visitor not found" }, status: :not_found }
11
+ end
12
+ end
13
+
14
+ def create
15
+ record = WhitelistedIp.find_or_initialize_by(ip: @visitor.ip)
16
+ record.visitor = @visitor
17
+ record.expires_at = params[:expires_at].presence || 7.days.from_now
18
+ record.save!
19
+ respond_to do |format|
20
+ format.html { redirect_back_or_to dashboard_path }
21
+ format.json { render json: { status: "ok", ip: @visitor.ip, expires_at: record.expires_at } }
22
+ end
23
+ rescue ActiveRecord::RecordInvalid => e
24
+ respond_to do |format|
25
+ format.html { redirect_back_or_to dashboard_path, alert: e.message }
26
+ format.json { render json: { status: "error", message: e.message }, status: :unprocessable_entity }
27
+ end
28
+ end
29
+
30
+ def destroy
31
+ record = @visitor.whitelisted_ip
32
+
33
+ if record
34
+ record.destroy!
35
+ respond_to do |format|
36
+ format.html { redirect_back_or_to dashboard_path }
37
+ format.json { render json: { status: "ok", ip: @visitor.ip } }
38
+ end
39
+ else
40
+ respond_to do |format|
41
+ format.html { redirect_back_or_to dashboard_path, alert: "No whitelist entry found." }
42
+ format.json { render json: { error: "Not whitelisted" }, status: :not_found }
43
+ end
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def authenticate_admin!
50
+ return if valid_api_token?
51
+
52
+ super
53
+ end
54
+
55
+ def set_visitor
56
+ @visitor = if params[:ip].present?
57
+ Visitor.find_by!(ip: params[:ip])
58
+ else
59
+ Visitor.find(params[:id])
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,18 @@
1
+ module Trackguard
2
+ class PageViewsController < ApplicationController
3
+ def create
4
+ PageViewRecorder.call(
5
+ path: params[:path].to_s,
6
+ ip: request.remote_ip,
7
+ user_agent: request.user_agent.to_s,
8
+ referer: request.referer,
9
+ session_id: session.id.to_s,
10
+ trace_id: params[:trace_id].to_s.presence,
11
+ source: params[:ref].to_s.strip.downcase.first(64).presence,
12
+ initial: params[:initial] == true
13
+ )
14
+
15
+ head :no_content
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,10 @@
1
+ module Trackguard
2
+ module ApplicationHelper
3
+ def trackguard_meta_tags
4
+ safe_join([
5
+ tag.meta(name: "trackguard-url", content: trackguard.page_views_path),
6
+ tag.meta(name: "trace-id", content: @trace_id)
7
+ ], "\n")
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,130 @@
1
+ module Trackguard
2
+ class DetectSuspiciousVisitorsJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ HARD_FLAG_THRESHOLD = 50
6
+ HIGH_VOLUME_MIN = 20
7
+ MEDIUM_VOLUME_MIN = 10
8
+ FLAG_SCORE_THRESHOLD = 6
9
+ MIN_VIEWS = 3
10
+
11
+ WEIGHTS = {
12
+ high_volume: 4,
13
+ medium_volume: 2,
14
+ no_session: 3,
15
+ no_referer: 2
16
+ }.freeze
17
+
18
+ def perform
19
+ recent_cutoff = 24.hours.ago
20
+
21
+ flag_shared_trace_id_visitors(recent_cutoff)
22
+
23
+ views_by_visitor = PageView
24
+ .where(created_at: recent_cutoff..)
25
+ .joins(:visitor)
26
+ .merge(Visitor.unflagged)
27
+ .preload(visitor: :whitelisted_ip)
28
+ .select(:visitor_id, :session_id, :referer, :path, :trace_id)
29
+ .group_by(&:visitor)
30
+
31
+ return if views_by_visitor.empty?
32
+
33
+ views_by_visitor.each do |visitor, views|
34
+ analyze_visitor(visitor, views)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
41
+ def analyze_visitor(visitor, views)
42
+ count = views.size
43
+ return if count.zero?
44
+ return if visitor.whitelisted_ip&.active?
45
+
46
+ if count >= HARD_FLAG_THRESHOLD
47
+ flag!(visitor, "#{count} page views in 24h (hard flag threshold)")
48
+ return
49
+ end
50
+
51
+ if (reason = ua_flag_reason(visitor.user_agent))
52
+ flag!(visitor, reason)
53
+ return
54
+ end
55
+
56
+ # Don't flag casual visitors with very few views — on a single-page site,
57
+ # legitimate users naturally hit only "/" once or twice.
58
+ return if count < MIN_VIEWS
59
+
60
+ if views.all? { |pv| pv.session_id.nil? && pv.referer.nil? && pv.path == "/" }
61
+ flag!(visitor, "no session, no referrer, single root hit")
62
+ return
63
+ end
64
+
65
+ score = 0
66
+ reasons = []
67
+
68
+ if count >= HIGH_VOLUME_MIN
69
+ score += WEIGHTS[:high_volume]
70
+ reasons << "#{count} page views in 24h"
71
+ elsif count >= MEDIUM_VOLUME_MIN
72
+ score += WEIGHTS[:medium_volume]
73
+ reasons << "#{count} page views in 24h"
74
+ end
75
+
76
+ if blank_ratio(views, :session_id) > 0.8
77
+ score += WEIGHTS[:no_session]
78
+ reasons << "#{pct(views, :session_id)}% of views had no session"
79
+ end
80
+
81
+ if blank_ratio(views, :referer) > 0.0
82
+ score += WEIGHTS[:no_referer]
83
+ reasons << "#{pct(views, :referer)}% of views had no referer"
84
+ end
85
+
86
+ return if score < FLAG_SCORE_THRESHOLD
87
+
88
+ flag!(visitor, reasons.join("; "))
89
+ end
90
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
91
+
92
+ def flag_shared_trace_id_visitors(cutoff)
93
+ shared = PageView
94
+ .where(created_at: cutoff..)
95
+ .where.not(trace_id: nil)
96
+ .group(:trace_id)
97
+ .having("COUNT(DISTINCT visitor_id) > 1")
98
+ .pluck(:trace_id)
99
+
100
+ return if shared.empty?
101
+
102
+ Visitor
103
+ .unflagged
104
+ .joins("LEFT OUTER JOIN trackguard_whitelisted_ips wi ON wi.visitor_id = trackguard_visitors.id")
105
+ .joins(:page_views)
106
+ .where(trackguard_page_views: { trace_id: shared, created_at: cutoff.. })
107
+ .where("wi.id IS NULL OR wi.expires_at <= ?", Time.current)
108
+ .distinct
109
+ .each do |visitor|
110
+ flag!(visitor, "trace_id shared across multiple visitors (cross-visitor bot detected)")
111
+ end
112
+ end
113
+
114
+ def ua_flag_reason(user_agent)
115
+ "blank or minimal user-agent" if user_agent.blank? || user_agent.to_s.length < 10
116
+ end
117
+
118
+ def flag!(visitor, reason)
119
+ visitor.update!(flagged_at: Time.current, flag_reason: reason, flagged_by: "claw:auto")
120
+ end
121
+
122
+ def blank_ratio(views, attr)
123
+ views.count { |v| v.public_send(attr).blank? }.to_f / views.size
124
+ end
125
+
126
+ def pct(views, attr)
127
+ (blank_ratio(views, attr) * 100).round
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,29 @@
1
+ module Trackguard
2
+ class TrackPageViewJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(path:, ip:, user_agent:, referer:, session_id: nil, trace_id: nil, source: nil, initial: false)
6
+ hashed_session_id = Digest::SHA256.hexdigest(session_id) if session_id.present?
7
+
8
+ visitor = Visitor.find_or_create_by!(ip: ip) do |v|
9
+ v.user_agent = user_agent
10
+ v.first_seen_at = Time.current
11
+ v.last_seen_at = Time.current
12
+ end
13
+ visitor.update!(last_seen_at: Time.current, user_agent: user_agent)
14
+
15
+ if initial && trace_id.present?
16
+ existing = PageView.find_by(trace_id: trace_id, visitor: visitor)
17
+ if existing
18
+ if path.include?("#") && !existing.path.include?("#") && path.start_with?("#{existing.path}#")
19
+ existing.update!(path: path)
20
+ end
21
+ return
22
+ end
23
+ end
24
+
25
+ PageView.create_with(source:, referer:)
26
+ .find_or_create_by!(path:, user_agent:, session_id: hashed_session_id, trace_id:, visitor:)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,16 @@
1
+ module Trackguard
2
+ class BlockedUserAgent < ApplicationRecord
3
+ self.table_name = "trackguard_blocked_user_agents"
4
+
5
+ CACHE_KEY = "trackguard/blocked_user_agent_patterns".freeze
6
+
7
+ validates :pattern, presence: true, uniqueness: true
8
+
9
+ def self.blocked?(user_agent)
10
+ patterns = Rails.cache.fetch(CACHE_KEY, expires_in: 10.minutes) do
11
+ pluck(:pattern)
12
+ end
13
+ patterns.any? { |p| user_agent.to_s.downcase.include?(p.downcase) }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ module Trackguard
2
+ class PageView < ApplicationRecord
3
+ self.table_name = "trackguard_page_views"
4
+
5
+ belongs_to :visitor, class_name: "Trackguard::Visitor"
6
+
7
+ validates :path, presence: true
8
+
9
+ scope :today, -> { where(created_at: Time.current.beginning_of_day..) }
10
+ scope :this_week, -> { where(created_at: 1.week.ago..) }
11
+ scope :this_month, -> { where(created_at: 1.month.ago..) }
12
+ scope :last_30, -> { where(created_at: 30.days.ago..) }
13
+ scope :last_24h, -> { where(created_at: 24.hours.ago..) }
14
+ scope :with_referrer, -> { where.not(referer: [ nil, "" ]) }
15
+ scope :with_source, -> { where.not(source: [ nil, "" ]) }
16
+ end
17
+ end
@@ -0,0 +1,24 @@
1
+ module Trackguard
2
+ class Visitor < ApplicationRecord
3
+ self.table_name = "trackguard_visitors"
4
+
5
+ FLAGGED_BY = [ "User", "claw:auto" ].freeze
6
+ CACHE_KEY = "trackguard/flagged_ips".freeze
7
+
8
+ validates :flagged_by, inclusion: { in: FLAGGED_BY }, allow_blank: true
9
+
10
+ has_many :page_views, class_name: "Trackguard::PageView", foreign_key: "visitor_id"
11
+ has_one :whitelisted_ip, class_name: "Trackguard::WhitelistedIp", foreign_key: "visitor_id"
12
+
13
+ scope :unflagged, -> { where(flagged_at: nil) }
14
+ scope :flagged, -> { where.not(flagged_at: nil) }
15
+
16
+ def self.flagged?(ip)
17
+ flagged_ips = Rails.cache.fetch(CACHE_KEY, expires_in: 5.minutes) do
18
+ flagged.pluck(:ip)
19
+ end
20
+
21
+ flagged_ips.include?(ip)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,26 @@
1
+ module Trackguard
2
+ class WhitelistedIp < ApplicationRecord
3
+ self.table_name = "trackguard_whitelisted_ips"
4
+
5
+ CACHE_KEY = "trackguard/whitelisted_ips".freeze
6
+
7
+ belongs_to :visitor, class_name: "Trackguard::Visitor", optional: true
8
+
9
+ validates :ip, presence: true, uniqueness: true
10
+ validates :expires_at, presence: true
11
+
12
+ scope :active, -> { where(expires_at: Time.current..) }
13
+
14
+ def self.whitelisted?(ip)
15
+ active_ips = Rails.cache.fetch(CACHE_KEY, expires_in: 10.minutes) do
16
+ active.pluck(:ip)
17
+ end
18
+
19
+ active_ips.include?(ip)
20
+ end
21
+
22
+ def active?
23
+ expires_at > Time.current
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,7 @@
1
+ module Trackguard
2
+ class ApplicationService
3
+ def self.call(...)
4
+ new(...).call
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,39 @@
1
+ module Trackguard
2
+ class PageViewRecorder < ApplicationService
3
+ BOT_REGEX = /
4
+ Googlebot|Bingbot|Slurp|DuckDuckBot|Baidu|YandexBot|
5
+ facebookexternalhit|Twitterbot|LinkedInBot|
6
+ curl|wget|python-requests|python-urllib|
7
+ Go-http-client|libwww|Java|Ruby|
8
+ bot|crawl|spider
9
+ /ix
10
+
11
+ def initialize(path:, ip:, user_agent:, referer:, session_id:, trace_id:, source: nil, initial: false)
12
+ @path = path.to_s
13
+ @ip = ip
14
+ @user_agent = user_agent.to_s
15
+ @referer = referer
16
+ @session_id = session_id
17
+ @trace_id = trace_id
18
+ @source = source.presence
19
+ @initial = initial
20
+ end
21
+
22
+ def call
23
+ return if BOT_REGEX.match?(@user_agent)
24
+ return if BlockedUserAgent.blocked?(@user_agent)
25
+ return if @path.blank? || @path.start_with?("/admin")
26
+
27
+ TrackPageViewJob.perform_later(
28
+ path: @path,
29
+ ip: @ip,
30
+ user_agent: @user_agent,
31
+ referer: @referer,
32
+ session_id: @session_id,
33
+ trace_id: @trace_id,
34
+ source: @source,
35
+ initial: @initial
36
+ )
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,68 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title><%= content_for?(:title) ? yield(:title) : "Trackguard" %></title>
7
+ <%= stylesheet_link_tag "trackguard/admin", media: "all" %>
8
+ </head>
9
+ <body class="tg-body">
10
+ <header class="tg-header">
11
+ <div class="tg-container">
12
+ <div class="tg-header__inner">
13
+ <span class="tg-brand">
14
+ <svg class="tg-brand__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 22" aria-hidden="true">
15
+ <defs>
16
+ <linearGradient id="tg-shield-grad" x1="0" y1="0" x2="0" y2="1">
17
+ <stop offset="0%" stop-color="#1e3a5f"/>
18
+ <stop offset="100%" stop-color="#0d1f3c"/>
19
+ </linearGradient>
20
+ </defs>
21
+ <!-- Shield -->
22
+ <path d="M10 1L19 4.5V11.5C19 16.4 15 20.3 10 21.5C5 20.3 1 16.4 1 11.5V4.5Z"
23
+ fill="url(#tg-shield-grad)" stroke="#3b82f6" stroke-width="1.25" stroke-linejoin="round"/>
24
+ <!-- Left rail -->
25
+ <line x1="7.5" y1="6" x2="7.5" y2="17" stroke="#60a5fa" stroke-width="1.6" stroke-linecap="round"/>
26
+ <!-- Right rail -->
27
+ <line x1="12.5" y1="6" x2="12.5" y2="17" stroke="#60a5fa" stroke-width="1.6" stroke-linecap="round"/>
28
+ <!-- Cross-tie 1 -->
29
+ <line x1="6.25" y1="8" x2="13.75" y2="8" stroke="#3b82f6" stroke-width="1.25" stroke-linecap="round"/>
30
+ <!-- Cross-tie 2 -->
31
+ <line x1="6.25" y1="11.5" x2="13.75" y2="11.5" stroke="#3b82f6" stroke-width="1.25" stroke-linecap="round"/>
32
+ <!-- Cross-tie 3 -->
33
+ <line x1="6.25" y1="15" x2="13.75" y2="15" stroke="#3b82f6" stroke-width="1.25" stroke-linecap="round"/>
34
+ </svg>
35
+ Trackguard
36
+ </span>
37
+
38
+ <a class="tg-back-link" href="<%= Trackguard.back_url %>">
39
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" aria-hidden="true">
40
+ <path stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
41
+ stroke-linejoin="round" fill="none" d="M10.5 3.5L5.5 8l5 4.5"/>
42
+ </svg>
43
+ <%= Trackguard.back_label %>
44
+ </a>
45
+ </div>
46
+ </div>
47
+ </header>
48
+ <nav class="tg-nav">
49
+ <div class="tg-container">
50
+ <a href="<%= dashboard_path %>" class="tg-nav__link<%= ' tg-nav__link--active' if request.path == dashboard_path %>">Dashboard</a>
51
+ <a href="<%= visits_path %>" class="tg-nav__link<%= ' tg-nav__link--active' if request.path.start_with?(visits_path) %>">All Visits</a>
52
+ </div>
53
+ </nav>
54
+ <main class="tg-main">
55
+ <div class="tg-container">
56
+ <%= yield %>
57
+ </div>
58
+ </main>
59
+ <script>
60
+ (function () {
61
+ var key = 'tg-scroll';
62
+ var saved = sessionStorage.getItem(key);
63
+ if (saved !== null) { window.scrollTo(0, parseInt(saved, 10)); sessionStorage.removeItem(key); }
64
+ document.addEventListener('submit', function () { sessionStorage.setItem(key, window.scrollY); });
65
+ })();
66
+ </script>
67
+ </body>
68
+ </html>