mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-27 17:37:58 +03:00
365 lines
12 KiB
Python
365 lines
12 KiB
Python
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||
#
|
||
# This file is part of Furious.
|
||
#
|
||
# This program is free software: you can redistribute it and/or modify
|
||
# it under the terms of the GNU General Public License as published by
|
||
# the Free Software Foundation, either version 3 of the License, or
|
||
# (at your option) any later version.
|
||
#
|
||
# This program is distributed in the hope that it will be useful,
|
||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
# GNU General Public License for more details.
|
||
#
|
||
# You should have received a copy of the GNU General Public License
|
||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
|
||
"""Define server profiles as metadata composed with connection documents."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Iterator, Mapping, MutableMapping
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import uuid
|
||
|
||
from .Configuration import ConfigFactory
|
||
|
||
__all__ = [
|
||
'ProfileMetadata',
|
||
'ServerProfile',
|
||
'connectionOf',
|
||
'ensureProfile',
|
||
'profileConnectionFingerprint',
|
||
]
|
||
|
||
|
||
@dataclass
|
||
class ProfileMetadata:
|
||
"""Store user and subscription metadata outside a connection document."""
|
||
|
||
displayName: str = ''
|
||
group: str = ''
|
||
tags: tuple[str, ...] = tuple()
|
||
subscriptionSource: str = ''
|
||
updatedAt: str = ''
|
||
annotations: str = ''
|
||
favorite: bool = False
|
||
latency: str = ''
|
||
speed: str = ''
|
||
extras: dict[str, Any] = field(default_factory=dict)
|
||
profileId: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||
subscriptionManaged: bool = False
|
||
subscriptionProfileKey: str = ''
|
||
|
||
@classmethod
|
||
def fromMapping(cls, value: Mapping[str, Any] | None = None, **kwargs):
|
||
"""Construct metadata from current or legacy persisted field names."""
|
||
data = dict(value or {})
|
||
data.update(kwargs)
|
||
nestedExtras = data.pop('extras', {})
|
||
tags = data.pop('tags', tuple()) or tuple()
|
||
|
||
if isinstance(tags, str):
|
||
tags = tuple(value.strip() for value in tags.split(',') if value.strip())
|
||
else:
|
||
tags = tuple(tags)
|
||
|
||
favorite = data.pop('favorite', False)
|
||
|
||
if isinstance(favorite, str):
|
||
favorite = favorite.strip().casefold() in ('1', 'true', 'yes', 'on')
|
||
|
||
subscriptionSource = str(
|
||
data.pop('subscriptionSource', data.pop('subsId', '')) or ''
|
||
)
|
||
subscriptionManaged = data.pop('subscriptionManaged', None)
|
||
|
||
if subscriptionManaged is None:
|
||
# Before managed ownership was explicit, a non-empty subsId was
|
||
# written only for profiles imported from that subscription.
|
||
subscriptionManaged = bool(subscriptionSource)
|
||
elif isinstance(subscriptionManaged, str):
|
||
subscriptionManaged = subscriptionManaged.strip().casefold() in (
|
||
'1',
|
||
'true',
|
||
'yes',
|
||
'on',
|
||
)
|
||
|
||
known = {
|
||
'profileId': str(data.pop('profileId', '') or uuid.uuid4()),
|
||
'displayName': data.pop('displayName', data.pop('remark', '')),
|
||
'group': data.pop('group', ''),
|
||
'tags': tags,
|
||
'subscriptionSource': subscriptionSource,
|
||
'subscriptionManaged': bool(subscriptionManaged),
|
||
'subscriptionProfileKey': str(data.pop('subscriptionProfileKey', '') or ''),
|
||
'updatedAt': data.pop('updatedAt', ''),
|
||
'annotations': data.pop('annotations', ''),
|
||
'favorite': bool(favorite),
|
||
'latency': data.pop('latency', data.pop('delayResult', '')),
|
||
'speed': data.pop('speed', data.pop('speedResult', '')),
|
||
}
|
||
|
||
extras = dict(nestedExtras) if isinstance(nestedExtras, Mapping) else {}
|
||
extras.update(data)
|
||
|
||
return cls(**known, extras=extras)
|
||
|
||
def toMapping(self) -> dict[str, Any]:
|
||
"""Return the normalized persisted metadata mapping."""
|
||
return {
|
||
'profileId': self.profileId,
|
||
'displayName': self.displayName,
|
||
'group': self.group,
|
||
'tags': list(self.tags),
|
||
'subscriptionSource': self.subscriptionSource,
|
||
'subscriptionManaged': self.subscriptionManaged,
|
||
'subscriptionProfileKey': self.subscriptionProfileKey,
|
||
'updatedAt': self.updatedAt,
|
||
'annotations': self.annotations,
|
||
'favorite': self.favorite,
|
||
'latency': self.latency,
|
||
'speed': self.speed,
|
||
'extras': dict(self.extras),
|
||
}
|
||
|
||
def set(self, name: str, value):
|
||
"""Set a current or legacy metadata field."""
|
||
aliases = {
|
||
'remark': 'displayName',
|
||
'subsId': 'subscriptionSource',
|
||
'delayResult': 'latency',
|
||
'speedResult': 'speed',
|
||
}
|
||
attribute = aliases.get(name, name)
|
||
|
||
if attribute == 'tags':
|
||
self.tags = (
|
||
tuple(item.strip() for item in value.split(',') if item.strip())
|
||
if isinstance(value, str)
|
||
else tuple(value or tuple())
|
||
)
|
||
elif attribute == 'favorite' and isinstance(value, str):
|
||
self.favorite = value.strip().casefold() in ('1', 'true', 'yes', 'on')
|
||
elif attribute == 'subscriptionSource':
|
||
self.subscriptionSource = str(value or '')
|
||
|
||
if not self.subscriptionSource:
|
||
self.subscriptionManaged = False
|
||
self.subscriptionProfileKey = ''
|
||
elif attribute in self.__dataclass_fields__ and attribute != 'extras':
|
||
setattr(self, attribute, value)
|
||
else:
|
||
self.extras[name] = value
|
||
|
||
|
||
@dataclass
|
||
class ServerProfile(MutableMapping[str, Any]):
|
||
"""Compose profile metadata with a core-neutral connection document."""
|
||
|
||
connection: ConfigFactory
|
||
metadata: ProfileMetadata = field(default_factory=ProfileMetadata)
|
||
index: int = 0
|
||
deleted: bool = False
|
||
|
||
@classmethod
|
||
def fromConfiguration(
|
||
cls,
|
||
configuration: ConfigFactory,
|
||
metadata: ProfileMetadata | Mapping[str, Any] | None = None,
|
||
*,
|
||
index: int = 0,
|
||
deleted: bool = False,
|
||
):
|
||
"""Move transient parser metadata into a separate profile object."""
|
||
if isinstance(configuration, ServerProfile):
|
||
return configuration
|
||
|
||
if not isinstance(configuration, ConfigFactory):
|
||
raise TypeError('profile connection must be a ConfigFactory')
|
||
|
||
if isinstance(metadata, ProfileMetadata):
|
||
profileMetadata = copy.deepcopy(metadata)
|
||
else:
|
||
profileMetadata = ProfileMetadata.fromMapping(metadata)
|
||
|
||
connection = configuration.deepcopy()
|
||
|
||
return cls(connection, profileMetadata, index, deleted)
|
||
|
||
def __getitem__(self, key: str):
|
||
"""Return a connection-document value."""
|
||
return self.connection[key]
|
||
|
||
def __setitem__(self, key: str, value):
|
||
"""Set a connection-document value."""
|
||
self.connection[key] = value
|
||
|
||
def __delitem__(self, key: str):
|
||
"""Delete a connection-document value."""
|
||
del self.connection[key]
|
||
|
||
def __iter__(self) -> Iterator[str]:
|
||
"""Iterate over connection-document keys."""
|
||
return iter(self.connection)
|
||
|
||
def __len__(self) -> int:
|
||
"""Return the connection-document size."""
|
||
return len(self.connection)
|
||
|
||
def deepcopy(self):
|
||
"""Return an independent profile copy."""
|
||
return copy.deepcopy(self)
|
||
|
||
def independentCopy(self):
|
||
"""Return a manual copy with a new identity and no source owner."""
|
||
profile = self.deepcopy()
|
||
profile.metadata.profileId = str(uuid.uuid4())
|
||
profile.metadata.subscriptionSource = ''
|
||
profile.metadata.subscriptionManaged = False
|
||
profile.metadata.subscriptionProfileKey = ''
|
||
|
||
return profile
|
||
|
||
def replaceConnection(self, connection: ConfigFactory):
|
||
"""Return this profile's metadata composed with a new connection."""
|
||
return ServerProfile.fromConfiguration(
|
||
connection,
|
||
self.metadata,
|
||
index=self.index,
|
||
deleted=self.deleted,
|
||
)
|
||
|
||
def coreName(self) -> str:
|
||
"""Return the runtime implementation name."""
|
||
return self.connection.coreName()
|
||
|
||
def isValid(self) -> bool:
|
||
"""Return whether the connection document is valid."""
|
||
return self.connection.isValid()
|
||
|
||
def toJSONString(self, **kwargs) -> str:
|
||
"""Serialize only the connection document as JSON."""
|
||
return self.connection.toJSONString(**kwargs)
|
||
|
||
def toURI(self, remark: str = '') -> str:
|
||
"""Serialize the connection document as a share URI."""
|
||
exportOptions = (
|
||
{'profileMetadata': self.metadata}
|
||
if self.itemProtocol.casefold() == 'shadowsocks'
|
||
else {}
|
||
)
|
||
|
||
return self.connection.toURI(
|
||
remark or self.metadata.displayName, **exportOptions
|
||
)
|
||
|
||
def httpProxy(self) -> str:
|
||
"""Return the connection's HTTP proxy endpoint."""
|
||
return self.connection.httpProxy()
|
||
|
||
def socksProxy(self) -> str:
|
||
"""Return the connection's SOCKS proxy endpoint."""
|
||
return self.connection.socksProxy()
|
||
|
||
def setHttpProxy(self, endpoint: str) -> bool:
|
||
"""Set the connection's HTTP proxy endpoint."""
|
||
return self.connection.setHttpProxy(endpoint)
|
||
|
||
def setSocksProxy(self, endpoint: str) -> bool:
|
||
"""Set the connection's SOCKS proxy endpoint."""
|
||
return self.connection.setSocksProxy(endpoint)
|
||
|
||
@property
|
||
def itemRemark(self) -> str:
|
||
"""Return the profile display name."""
|
||
return self.metadata.displayName
|
||
|
||
@property
|
||
def itemProtocol(self) -> str:
|
||
"""Return the connection protocol display value."""
|
||
return str(getattr(self.connection, 'itemProtocol', ''))
|
||
|
||
@property
|
||
def itemAddress(self) -> str:
|
||
"""Return the connection address display value."""
|
||
return str(getattr(self.connection, 'itemAddress', ''))
|
||
|
||
@property
|
||
def itemPort(self) -> str:
|
||
"""Return the connection port display value."""
|
||
return str(getattr(self.connection, 'itemPort', ''))
|
||
|
||
@property
|
||
def itemTransport(self) -> str:
|
||
"""Return the connection transport display value."""
|
||
return str(getattr(self.connection, 'itemTransport', ''))
|
||
|
||
@property
|
||
def itemTLS(self) -> str:
|
||
"""Return the connection TLS display value."""
|
||
return str(getattr(self.connection, 'itemTLS', ''))
|
||
|
||
@property
|
||
def itemSubscription(self) -> str:
|
||
"""Return the subscription source identifier."""
|
||
return self.metadata.subscriptionSource
|
||
|
||
@property
|
||
def itemSubscriptionManaged(self) -> bool:
|
||
"""Return whether a subscription synchronizer owns this profile."""
|
||
return self.metadata.subscriptionManaged
|
||
|
||
@property
|
||
def itemLatency(self) -> str:
|
||
"""Return the last latency result."""
|
||
return self.metadata.latency
|
||
|
||
@property
|
||
def itemSpeed(self) -> str:
|
||
"""Return the last speed result."""
|
||
return self.metadata.speed
|
||
|
||
|
||
def connectionOf(value):
|
||
"""Return a server profile's connection document or *value* itself."""
|
||
return value.connection if isinstance(value, ServerProfile) else value
|
||
|
||
|
||
def ensureProfile(value, **metadata) -> ServerProfile:
|
||
"""Return *value* as a profile, merging optional metadata fields."""
|
||
if isinstance(value, ServerProfile):
|
||
for name, item in metadata.items():
|
||
value.metadata.set(name, item)
|
||
|
||
return value
|
||
|
||
return ServerProfile.fromConfiguration(value, metadata)
|
||
|
||
|
||
def profileConnectionFingerprint(value) -> str:
|
||
"""Return a deterministic identity for a profile connection document."""
|
||
connection = connectionOf(value)
|
||
|
||
try:
|
||
serialized = json.dumps(
|
||
connection,
|
||
ensure_ascii=False,
|
||
separators=(',', ':'),
|
||
sort_keys=True,
|
||
)
|
||
except Exception:
|
||
# Any non-exit exceptions
|
||
|
||
serializer = getattr(connection, 'toJSONString', None)
|
||
serialized = serializer(indent=0) if callable(serializer) else str(connection)
|
||
|
||
return hashlib.sha256(serialized.encode('utf-8')).hexdigest()
|