Skip to content

fix(profile): read subscription expire as an absolute instant - #2356

Open
kokhlo wants to merge 4 commits into
hiddify:mainfrom
kokhlo:fix/subscription-expire-instant-2330
Open

kokhlo wants to merge 4 commits into
hiddify:mainfrom
kokhlo:fix/subscription-expire-instant-2330

Conversation

@kokhlo

@kokhlo kokhlo commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A subscription that is valid renders as Expired on every import. The value is not garbage and the
panel is not lying about the plan — the client simply feeds a number that is not an absolute
timestamp into DateTime.fromMillisecondsSinceEpoch, lands in 1970, and the three remainingText()
implementations in profile_tile.dart paint it red.

_parseSubscriptionInfo special-cases exactly one non-instant value, expire == 0, and treats
everything else as a UNIX timestamp in seconds. Panels in the wild also send a negative sentinel
for "never expires" and a relative duration (days/hours) instead of an absolute instant. Both fall
straight through into the 1970 branch:

expire sent resulting DateTime UI
7 (7 days) 1970-01-01 Expired
-1 (never) 1969-12-31 Expired
<unix seconds> correct remaining days

Root cause

lib/features/profile/data/profile_parser.dart, _parseSubscriptionInfo:

expire = (expire == null || expire == 0) ? infiniteTimeThreshold : expire;
...
expire: DateTime.fromMillisecondsSinceEpoch(expire * 1000),

SubscriptionInfo.isExpired is expire <= DateTime.now() (profile_entity.dart:60), so any value
below "now" is reported as expired. The == 0 guard is the only thing standing between a duration and
a 1970 date, and it does not cover -1 or a small duration.

Fix

Validate that expire can actually be an absolute instant before trusting it; anything that cannot is
treated as "never expires", which is the same conclusion the client already reaches for 0:

  • <= 0 — covers the negative sentinels panels use for unlimited.
  • < 1_000_000_000 (2001-09-09) — a relative value in seconds/days, not an instant.
  • > 253_402_300_799 (9999-12-31) — would overflow the millisecond arithmetic.

The same pass also stops a subscription-userinfo value with a stray ; from throwing
RangeError inside the map comprehension. v.split('=').second on a segment that has no = (a
trailing ;, or ;;) throws, and because parse() runs inside
Either.tryCatch(..., ProfileFailure.unexpected) that discarded the entire profile — a malformed
optional header failing the whole import. Segments are now filtered to those that actually contain
=, and the value is read with .last so a value containing = is not truncated.

Testing

Three cases added to test/features/profile/data/profile_parser_test.dart, matching the existing
header-map style in that file:

  • expire=7 is treated as unlimited and isExpired is false (was: red Expired).
  • A real future timestamp is preserved exactly — the fix does not swallow valid expiries.
  • A trailing ; no longer discards the profile (was: RangeError → ProfileFailure).

Verified against a standalone harness that extracts the parser body out of this repository's
profile_parser.dart
(rather than a hand-copied duplicate), renames only the two private
identifiers so it can be called from main(), and renders through the same isExpired / ratio /
remaining rules the tile uses. 21 checks, run against origin/main and against this branch:

                                  origin/main                      this branch
expire=7 (7 days)                 *** EXPIRED ***                 remaining: ∞
expire=-1 (never)                 *** EXPIRED ***                 remaining: ∞
expire=999999999                  *** EXPIRED ***                 remaining: ∞
expire=<future unix ts>           remaining: 6 days               remaining: 6 days
trailing ";" / ";;"               RangeError, uncaught            remaining: 6 days
genuinely past ts (1000000000)    *** EXPIRED ***                 *** EXPIRED ***

The last row matters: a timestamp that really is in the past still reports expired, so the fix does
not paper over genuine expiry. total=0 still maps to the unlimited-traffic sentinel, and the two
cases that were already null before this change (missing expire key, capitalised keys) are
unchanged.

This is ad-hoc verification, not the project's test suite. I did try to run the real thing: Flutter
3.47.5 is available here, flutter pub get succeeded, but build_runner — which generates the
.freezed.dart / .g.dart files this package needs (none are checked in) — produced nothing after
four minutes while the disk went from 97% to 99%, so I stopped it rather than risk the disk for a
green run. The three cases added to test/features/profile/data/profile_parser_test.dart therefore
have not been executed by me or by CI. Running
flutter test test/features/profile/data/profile_parser_test.dart before merge would close that gap.

I could not read the reporter's actual response headers (the subscription is behind a paid login), so
the exact form v2sx.com sends is inferred from the WHMCS v2ray modules that emit this header. If the
panel does send a proper absolute timestamp and something else is responsible, that would be worth a
follow-up with the raw header attached — the parsing above is defensive and does not change the
behaviour for well-formed input.

#2330

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant