gitscope
← marzneshin / Diff · fb4b69d → fb4b69d
Changes fb4b69d..fb4b69d
Patch commit
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: "<missing>",
@@ -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<HTMLDivElement>,
- VariantProps<typeof badgeVariants> { }
+ extends React.HTMLAttributes<HTMLDivElement>,
+ VariantProps<typeof badgeVariants> { }
function Badge({ className, variant, ...props }: BadgeProps) {
- return (
- <div className={cn(badgeVariants({ variant }), className)} {...props} />
- )
+ return (
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
+ )
}
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 (
+ <FormField
+ control={form.control}
+ name="placeholder_if_disabled"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md py-2">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>
+ {t("page.settings.subscription-settings.placeholder-if-disabled")}
+ </FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ )
+}
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 (
+ <FormField
+ control={form.control}
+ name="placeholder_remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex flex-row items-center gap-2">
+ {t("page.settings.subscription-settings.placeholder-remark")}
+ <Popover>
+ <PopoverTrigger>
+ <InfoIcon className="size-5" />
+ </PopoverTrigger>
+ <PopoverContent className="w-80">
+ <PopoverGuide />
+ </PopoverContent>
+ </Popover>
+ </FormLabel>
+ <FormControl>
+ <Input className="h-8" {...field} />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ )
+}
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 (
+ <FormField
+ control={form.control}
+ name="shuffle_configs"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md py-2">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>
+ {t("page.settings.subscription-settings.shuffle-configs")}
+ </FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ )
+}
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 = () => {
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
- {t("page.settings.template-on-acceptance")}
+ {t("page.settings.subscription-settings.template-on-acceptance")}
</FormLabel>
</div>
</FormItem>
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() {
<SupportLinkField />
</div>
<TemplateOnAcceptanceField />
+ <PlaceholderRemarkField />
+ <PlaceholderIfDisabledField />
+ <ShuffleConfigsField />
<Separator className="my-3" />
<h4 className="text-lg mt-2">
{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 = () => {
</>
) : (
<Link to="/settings">
- <Settings className="bg-gray-800 size-10 rounded-md text-2xl p-2" />
+ <Settings className="bg-gray-800 text-secondary dark:hover:bg-secondary-foreground dark:hover:text-secondary dark:text-secondary-foreground size-10 rounded-md text-2xl p-2" />
</Link>
)
}
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