diff --git a/app/models/settings.py b/app/models/settings.py index d2714c5..4cb7c1f 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -25,6 +25,9 @@ class SubscriptionSettings(BaseModel): profile_title: str support_link: str update_interval: int + shuffle_configs: bool = False + placeholder_if_disabled: bool = True + placeholder_remark: str = "disabled" rules: list[SubscriptionRule] diff --git a/app/routes/subscription.py b/app/routes/subscription.py index 969edb3..70eeae3 100644 --- a/app/routes/subscription.py +++ b/app/routes/subscription.py @@ -3,18 +3,18 @@ from collections import defaultdict from fastapi import APIRouter from fastapi import Header, HTTPException, Path, Request, Response -from fastapi.responses import HTMLResponse +from starlette.responses import HTMLResponse -from app.config.env import ( - SUBSCRIPTION_PAGE_TEMPLATE, -) from app.db import crud from app.db.models import Settings from app.dependencies import DBDep, SubUserDep, StartDateDep, EndDateDep from app.models.settings import SubscriptionSettings from app.models.user import UserResponse -from app.templates import render_template -from app.utils.share import encode_title, generate_subscription +from app.utils.share import ( + encode_title, + generate_subscription, + generate_subscription_template, +) router = APIRouter(prefix="/sub", tags=["Subscription"]) @@ -39,7 +39,9 @@ def get_subscription_user_info(user: UserResponse) -> dict: "upload": 0, "download": user.used_traffic, "total": user.data_limit, - "expire": user.expire, + "expire": ( + user.expire_date if user.expire_strategy == "fixed_date" else None + ), } @@ -66,14 +68,8 @@ def user_subscription( subscription_settings.template_on_acceptance and "text/html" in request.headers.get("Accept", []) ): - links = generate_subscription( - user=db_user, config_format="links" - ).split() return HTMLResponse( - render_template( - SUBSCRIPTION_PAGE_TEMPLATE, - {"user": user, "links": links}, - ) + generate_subscription_template(db_user, subscription_settings) ) response_headers = { @@ -92,13 +88,9 @@ def user_subscription( for rule in subscription_settings.rules: if re.match(rule.pattern, user_agent): if rule.result.value == "template": - links = generate_subscription( - user=db_user, config_format="links" - ).split() return HTMLResponse( - render_template( - SUBSCRIPTION_PAGE_TEMPLATE, - {"user": user, "links": links}, + generate_subscription_template( + db_user, subscription_settings ) ) elif rule.result.value == "block": @@ -114,6 +106,10 @@ def user_subscription( user=db_user, config_format=config_format, as_base64=b64, + use_placeholder=not user.is_active + and subscription_settings.placeholder_if_disabled, + placeholder_remark=subscription_settings.placeholder_remark, + shuffle=subscription_settings.shuffle_configs, ) return Response( content=conf, @@ -139,6 +135,15 @@ def user_get_usage( return {"usages": usages, "username": db_user.username} +client_type_mime_type = { + "sing-box": "application/json", + "clash-meta": "text/yaml", + "clash": "text/yaml", + "xray": "application/json", + "v2ray": "text/plain", +} + + @router.get("/{username}/{key}/{client_type}") def user_subscription_with_client_type( db: DBDep, @@ -169,39 +174,17 @@ def user_subscription_with_client_type( ), } - if client_type == "clash-meta": - conf = generate_subscription(user=db_user, config_format="clash-meta") - return Response( - content=conf, media_type="text/yaml", headers=response_headers - ) - - elif client_type == "sing-box": - conf = generate_subscription(user=db_user, config_format="sing-box") - return Response( - content=conf, - media_type="application/json", - headers=response_headers, - ) - elif client_type == "clash": - conf = generate_subscription(user=db_user, config_format="clash") - return Response( - content=conf, media_type="text/yaml", headers=response_headers - ) - - elif client_type == "v2ray": - conf = generate_subscription( - user=db_user, config_format="links", as_base64=True - ) - return Response( - content=conf, media_type="text/plain", headers=response_headers - ) - elif client_type == "xray": - return Response( - content=generate_subscription(user=db_user, config_format="xray"), - media_type="application/json", - headers=response_headers, - ) - else: - raise HTTPException( - status_code=400, detail="Invalid subscription type" - ) + conf = generate_subscription( + user=db_user, + config_format="links" if client_type == "v2ray" else client_type, + as_base64=client_type == "v2ray", + use_placeholder=not user.is_active + and subscription_settings.placeholder_if_disabled, + placeholder_remark=subscription_settings.placeholder_remark, + shuffle=subscription_settings.shuffle_configs, + ) + return Response( + content=conf, + media_type=client_type_mime_type[client_type], + headers=response_headers, + ) diff --git a/app/utils/share.py b/app/utils/share.py index 9ad3837..67feb35 100644 --- a/app/utils/share.py +++ b/app/utils/share.py @@ -22,20 +22,23 @@ from app.config.env import ( XRAY_SUBSCRIPTION_TEMPLATE, SINGBOX_SUBSCRIPTION_TEMPLATE, CLASH_SUBSCRIPTION_TEMPLATE, + SUBSCRIPTION_PAGE_TEMPLATE, ) from app.db import GetDB, crud from app.models.proxy import ( InboundHostSecurity, ) +from app.models.settings import SubscriptionSettings from app.models.user import UserResponse, UserExpireStrategy +from app.templates import render_template from app.utils.keygen import gen_uuid, gen_password from app.utils.system import get_public_ip, readable_size SERVER_IP = get_public_ip() -STATUS_EMOJIS = { - "active": "✅", - "inactive": "❌", +ACTIVITY_EMOJIS = { + True: "✅", + False: "❌", } subscription_handlers = { @@ -57,14 +60,32 @@ handlers_templates = { } +def generate_subscription_template( + db_user, subscription_settings: SubscriptionSettings +): + print(subscription_settings.shuffle_configs) + links = generate_subscription( + user=db_user, + config_format="links", + use_placeholder=not db_user.is_active + and subscription_settings.placeholder_if_disabled, + placeholder_remark=subscription_settings.placeholder_remark, + shuffle=subscription_settings.shuffle_configs, + ).split() + return render_template( + SUBSCRIPTION_PAGE_TEMPLATE, + {"user": UserResponse.model_validate(db_user), "links": links}, + ) + + def generate_subscription( user: "UserResponse", config_format: Literal["links", "xray", "clash-meta", "clash", "sing-box"], as_base64: bool = False, + use_placeholder: bool = False, + placeholder_remark: str = "disabled", + shuffle: bool = False, ) -> str: - - inbounds = user.inbounds - key = user.key extra_data = UserResponse.model_validate(user).model_dump( exclude={"subscription_url", "services", "inbounds"} ) @@ -81,9 +102,27 @@ def generate_subscription( else: subscription_handler = subscription_handler_class() - config = process_inbounds_and_tags( - inbounds, key, format_variables, conf=subscription_handler - ) + if use_placeholder: + placeholder_config = V2Data( + "vmess", + placeholder_remark.format_map(format_variables), + "127.0.0.1", + 80, + ) + configs = [placeholder_config] + + else: + configs = generate_user_configs( + user.inbounds, + user.key, + format_variables, + ) + + if shuffle: + random.shuffle(configs) + + subscription_handler.add_proxies(configs) + config = subscription_handler.render() return ( config if not as_base64 else base64.b64encode(config.encode()).decode() @@ -147,10 +186,9 @@ def setup_format_variables(extra_data: dict) -> dict: data_left = 0 data_left = readable_size(data_left) else: - data_limit = "∞" - data_left = "∞" + data_limit, data_left = "∞" * 2 - status_emoji = STATUS_EMOJIS.get(extra_data.get("status")) or "" + status_emoji = ACTIVITY_EMOJIS.get(extra_data.get("is_active")) or "" format_variables = defaultdict( lambda: "", @@ -171,13 +209,14 @@ def setup_format_variables(extra_data: dict) -> dict: return format_variables -def process_inbounds_and_tags( +def generate_user_configs( inbounds: list, key: str, format_variables: dict, - conf=None, ) -> Union[List, str]: + salt = secrets.token_hex(8) + configs = [] for inb in inbounds: inb_id, inbound, protocol, tag = ( @@ -248,8 +287,9 @@ def process_inbounds_and_tags( data.fragment_packets = host.fragment["packets"] data.fragment_length = host.fragment["length"] data.fragment_interval = host.fragment["interval"] - conf.add_proxies([data]) - return conf.render() + configs.append(data) + + return configs def encode_title(text: str) -> str: diff --git a/dashboard/public/locales/en.json b/dashboard/public/locales/en.json index 215b049..e2631bf 100644 --- a/dashboard/public/locales/en.json +++ b/dashboard/public/locales/en.json @@ -67,8 +67,11 @@ }, "page": { "settings": { - "template-on-acceptance": "Template on Acceptance", "subscription-settings": { + "template-on-acceptance": "Template on Acceptance", + "placeholder-remark": "Placeholder Remark", + "placeholder-if-disabled": "Placeholder if Disabled", + "shuffle-configs": "Shuffle Configs", "url-prefix": "URL Prefix", "profile-title": "Profile Title", "support-link": "Support Link", diff --git a/dashboard/src/components/ui/badge.tsx b/dashboard/src/components/ui/badge.tsx index 04ed41c..88a7db5 100644 --- a/dashboard/src/components/ui/badge.tsx +++ b/dashboard/src/components/ui/badge.tsx @@ -4,51 +4,51 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@marzneshin/utils" const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", - { - variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", - secondary: - "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", - destructive: - "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", - royal: - "border-transparent bg-indigo-300 text-indigo-700 hover:bg-indigo/80", - positive: - "border-transparent bg-emerald-300 text-emerald-700 hover:bg-emerald/80", - disabled: - "border-transparent bg-gray-400 text-gray-800 hover:bg-gray/80", - warning: - "border-transparent bg-amber-300 text-amber-700 hover:bg-amber/80", - outline: "text-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + royal: + "border-transparent text-indigo-200 bg-indigo-700 hover:bg-indigo/80", + positive: + "border-transparent text-emerald-100 bg-emerald-800 hover:bg-emerald/80", + disabled: + "border-transparent bg-gray-400 text-gray-800 hover:bg-gray/80", + warning: + "border-transparent bg-amber-200 text-amber-700 hover:bg-amber/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } ) export interface BadgeProps - extends React.HTMLAttributes, - VariantProps { } + extends React.HTMLAttributes, + VariantProps { } function Badge({ className, variant, ...props }: BadgeProps) { - return ( -
- ) + return ( +
+ ) } type BadgeVariantKeys = - | 'default' - | 'secondary' - | 'destructive' - | 'royal' - | 'positive' - | 'disabled' - | 'warning' - | 'outline'; + | 'default' + | 'secondary' + | 'destructive' + | 'royal' + | 'positive' + | 'disabled' + | 'warning' + | 'outline'; export { Badge, badgeVariants, type BadgeVariantKeys } diff --git a/dashboard/src/features/hosts/components/index.ts b/dashboard/src/features/hosts/components/index.ts new file mode 100644 index 0000000..d585ca9 --- /dev/null +++ b/dashboard/src/features/hosts/components/index.ts @@ -0,0 +1 @@ +export * from "./popover-guide" diff --git a/dashboard/src/features/hosts/dialogs/mutation/fields/popover-guide.tsx b/dashboard/src/features/hosts/components/popover-guide.tsx similarity index 100% rename from dashboard/src/features/hosts/dialogs/mutation/fields/popover-guide.tsx rename to dashboard/src/features/hosts/components/popover-guide.tsx diff --git a/dashboard/src/features/hosts/dialogs/mutation/fields/remark.tsx b/dashboard/src/features/hosts/dialogs/mutation/fields/remark.tsx index 46a317c..1a859fb 100644 --- a/dashboard/src/features/hosts/dialogs/mutation/fields/remark.tsx +++ b/dashboard/src/features/hosts/dialogs/mutation/fields/remark.tsx @@ -9,7 +9,7 @@ import { PopoverTrigger, PopoverContent, } from "@marzneshin/components"; -import { PopoverGuide } from './popover-guide'; +import { PopoverGuide } from '@marzneshin/features/hosts'; import { useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { InfoIcon } from 'lucide-react'; diff --git a/dashboard/src/features/hosts/index.ts b/dashboard/src/features/hosts/index.ts index db31fdb..83191d3 100644 --- a/dashboard/src/features/hosts/index.ts +++ b/dashboard/src/features/hosts/index.ts @@ -4,3 +4,4 @@ export * from './schema' export * from './services' export * from './dialogs' export * from './router-context' +export * from './components' diff --git a/dashboard/src/features/subscription-settings/form/fields/index.ts b/dashboard/src/features/subscription-settings/form/fields/index.ts index 02fc32f..2d1cc65 100644 --- a/dashboard/src/features/subscription-settings/form/fields/index.ts +++ b/dashboard/src/features/subscription-settings/form/fields/index.ts @@ -3,3 +3,6 @@ export * from "./profile-title" export * from "./support-link" export * from "./update-interval" export * from "./template-on-acceptance" +export * from "./placeholder-remark" +export * from "./placeholder-if-disabled" +export * from "./shuffle-configs" diff --git a/dashboard/src/features/subscription-settings/form/fields/placeholder-if-disabled.tsx b/dashboard/src/features/subscription-settings/form/fields/placeholder-if-disabled.tsx new file mode 100644 index 0000000..4f591a6 --- /dev/null +++ b/dashboard/src/features/subscription-settings/form/fields/placeholder-if-disabled.tsx @@ -0,0 +1,35 @@ +import { + FormField, + FormItem, + FormLabel, + Checkbox, + FormControl, +} from "@marzneshin/components"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +export const PlaceholderIfDisabledField = () => { + const { t } = useTranslation() + const form = useFormContext(); + return ( + ( + + + + +
+ + {t("page.settings.subscription-settings.placeholder-if-disabled")} + +
+
+ )} + /> + ) +} diff --git a/dashboard/src/features/subscription-settings/form/fields/placeholder-remark.tsx b/dashboard/src/features/subscription-settings/form/fields/placeholder-remark.tsx new file mode 100644 index 0000000..c2210d4 --- /dev/null +++ b/dashboard/src/features/subscription-settings/form/fields/placeholder-remark.tsx @@ -0,0 +1,43 @@ +import { + FormField, + FormItem, + FormLabel, + Input, + FormControl, + Popover, + PopoverTrigger, + PopoverContent, +} from "@marzneshin/components"; +import { PopoverGuide } from "@marzneshin/features/hosts"; +import { InfoIcon } from 'lucide-react'; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +export const PlaceholderRemarkField = () => { + const { t } = useTranslation() + const form = useFormContext(); + return ( + ( + + + {t("page.settings.subscription-settings.placeholder-remark")} + + + + + + + + + + + + + + )} + /> + ) +} diff --git a/dashboard/src/features/subscription-settings/form/fields/shuffle-configs.tsx b/dashboard/src/features/subscription-settings/form/fields/shuffle-configs.tsx new file mode 100644 index 0000000..bf2fa53 --- /dev/null +++ b/dashboard/src/features/subscription-settings/form/fields/shuffle-configs.tsx @@ -0,0 +1,35 @@ +import { + FormField, + FormItem, + FormLabel, + Checkbox, + FormControl, +} from "@marzneshin/components"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +export const ShuffleConfigsField = () => { + const { t } = useTranslation() + const form = useFormContext(); + return ( + ( + + + + +
+ + {t("page.settings.subscription-settings.shuffle-configs")} + +
+
+ )} + /> + ) +} diff --git a/dashboard/src/features/subscription-settings/form/fields/template-on-acceptance.tsx b/dashboard/src/features/subscription-settings/form/fields/template-on-acceptance.tsx index 6939fdb..dcdec8b 100644 --- a/dashboard/src/features/subscription-settings/form/fields/template-on-acceptance.tsx +++ b/dashboard/src/features/subscription-settings/form/fields/template-on-acceptance.tsx @@ -2,7 +2,6 @@ import { FormField, FormItem, FormLabel, - Checkbox, FormControl, } from "@marzneshin/components"; @@ -26,7 +25,7 @@ export const TemplateOnAcceptanceField = () => {
- {t("page.settings.template-on-acceptance")} + {t("page.settings.subscription-settings.template-on-acceptance")}
diff --git a/dashboard/src/features/subscription-settings/form/form.tsx b/dashboard/src/features/subscription-settings/form/form.tsx index 7bc0d7f..99911fb 100644 --- a/dashboard/src/features/subscription-settings/form/form.tsx +++ b/dashboard/src/features/subscription-settings/form/form.tsx @@ -19,7 +19,10 @@ import { TemplateOnAcceptanceField, ProfileTitleField, SupportLinkField, - UpdateIntervalField + UpdateIntervalField, + PlaceholderRemarkField, + PlaceholderIfDisabledField, + ShuffleConfigsField } from "./fields"; import { NoRulesAlert } from "./no-rules-alert"; import { @@ -80,6 +83,9 @@ export function SubscriptionRulesForm() {
+ + +

{t("page.settings.subscription-settings.subscription-title")} diff --git a/dashboard/src/features/subscription-settings/form/schema.ts b/dashboard/src/features/subscription-settings/form/schema.ts index e3adfcb..4a0eab3 100644 --- a/dashboard/src/features/subscription-settings/form/schema.ts +++ b/dashboard/src/features/subscription-settings/form/schema.ts @@ -9,6 +9,9 @@ export const schema = z.object({ }) ), // url_prefix: z.string().default(""), + shuffle_configs: z.boolean(), + placeholder_if_disabled: z.boolean(), + placeholder_remark: z.string(), template_on_acceptance: z.boolean().default(false), profile_title: z.string().default(""), support_link: z.string().default(""), diff --git a/dashboard/src/globals.css b/dashboard/src/globals.css index 72b8b04..4740459 100644 --- a/dashboard/src/globals.css +++ b/dashboard/src/globals.css @@ -74,18 +74,15 @@ --input: 217.2 32.6% 17.5%; --ring: 212.7 26.8% 83.9%; } - - } - @layer base { .font-body { - font-family: 'Lato'; + font-family: "Lato", sans-serif; } .font-header { - font-family: 'Ubuntu'; + font-family: "Ubuntu", sans-serif; } .vstack { diff --git a/dashboard/src/routes/_dashboard.tsx b/dashboard/src/routes/_dashboard.tsx index adb35d3..d93d3ab 100644 --- a/dashboard/src/routes/_dashboard.tsx +++ b/dashboard/src/routes/_dashboard.tsx @@ -52,7 +52,7 @@ export const DashboardLayout = () => { ) : ( - + ) } diff --git a/requirements.txt b/requirements.txt index a2e1dac..ea3be81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,5 +34,5 @@ python-multipart==0.0.7 xxhash==3.4.1 grpcio==1.60.0 psycopg==3.1.18 -v2share==0.1.0b9 +v2share==0.1.0b10 PyJWT~=2.8.0 \ No newline at end of file