diff --git a/app/db/migrations/versions/9001356f0063_create_settings.py b/app/db/migrations/versions/9001356f0063_create_settings.py new file mode 100644 index 0000000..9b51fd9 --- /dev/null +++ b/app/db/migrations/versions/9001356f0063_create_settings.py @@ -0,0 +1,70 @@ +"""empty message + +Revision ID: 9001356f0063 +Revises: 058eb11c5b17 +Create Date: 2024-06-08 21:59:45.214144 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "9001356f0063" +down_revision = "058eb11c5b17" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + settings_table = op.create_table( + "settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("subscription", sa.JSON(), nullable=False), + sa.Column("telegram", sa.JSON()), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + op.bulk_insert( + settings_table, + [ + { + "telegram": None, + "subscription": { + "url_prefix": "", + "profile_title": "Support", + "support_link": "t.me/support", + "update_interval": 12, + "rules": [ + { + "pattern": "^([Cc]lash-verge|[Cc]lash-?[Mm]eta)", + "result": "clash-meta", + }, + {"pattern": "^([Cc]lash|[Ss]tash)", "result": "clash"}, + { + "pattern": "^(SFA|SFI|SFM|SFT|[Kk]aring|[Hh]iddify[Nn]ext)", + "result": "sing-box", + }, + { + "pattern": "^v2rayN/(:?6\.(:?[5-9]\d+|4[1-9])|[7-9]\d*\.\d+)", + "result": "xray", + }, + {"pattern": "^v2rayN/", "result": "base64-links"}, + { + "pattern": "^v2rayNG/([2-9]|1\.(9|\d{2,})|1\.8\.(1[6-9]|[2-9]\d|\d{3,}))", + "result": "xray", + }, + {"pattern": "^v2rayNG/", "result": "base64-links"}, + {"pattern": ".*", "result": "template"}, + ], + }, + } + ], + ) + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("settings") + # ### end Alembic commands ### diff --git a/app/db/models.py b/app/db/models.py index 096c95b..291c08d 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -314,3 +314,11 @@ class NotificationReminder(Base): type = Column(Enum(ReminderType), nullable=False) expires_at = Column(DateTime) created_at = Column(DateTime, default=datetime.utcnow) + + +class Settings(Base): + __tablename__ = "settings" + + id = Column(Integer, primary_key=True, server_default=text("0")) + subscription = Column(JSON, nullable=False) + telegram = Column(JSON) diff --git a/app/models/settings.py b/app/models/settings.py new file mode 100644 index 0000000..be4c01e --- /dev/null +++ b/app/models/settings.py @@ -0,0 +1,39 @@ +from enum import Enum +from typing import Pattern + +from pydantic import BaseModel + + +class ConfigTypes(str, Enum): + links = "links" + base64_links = "base64-links" + xray = "xray" + sing_box = "sing-box" + clash = "clash" + clash_meta = "clash-meta" + template = "template" + block = "block" + + +class SubscriptionRule(BaseModel): + pattern: Pattern + result: ConfigTypes + + +class SubscriptionSettings(BaseModel): + url_prefix: str + profile_title: str + support_link: str + update_interval: int + rules: list[SubscriptionRule] + + +class TelegramSettings(BaseModel): + token: str + admin_id: list[int] + channel_id: int | None + + +class Settings(BaseModel): + subscription: SubscriptionSettings + telegram: TelegramSettings | None diff --git a/app/routes/subscription.py b/app/routes/subscription.py index ca993da..bbf3365 100644 --- a/app/routes/subscription.py +++ b/app/routes/subscription.py @@ -1,24 +1,39 @@ import re +from collections import defaultdict from fastapi import APIRouter from fastapi import Header, HTTPException, Path, Request, Response from fastapi.responses import HTMLResponse 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 config import ( - SUB_PROFILE_TITLE, - SUB_SUPPORT_URL, - SUB_UPDATE_INTERVAL, SUBSCRIPTION_PAGE_TEMPLATE, ) router = APIRouter(prefix="/sub", tags=["Subscription"]) +config_mimetype = defaultdict( + lambda: "text/plain", + { + "links": "text/plain", + "base64-links": "text/plain", + "sing-box": "application/json", + "xray": "application/json", + "clash": "text/yaml", + "clash-meta": "text/yaml", + "template": "text/html", + "block": "text/plain", + }, +) + + def get_subscription_user_info(user: UserResponse) -> dict: return { "upload": 0, @@ -38,26 +53,21 @@ def user_subscription( """ Subscription link, V2ray and Clash supported """ - accept_header = request.headers.get("Accept", "") user: UserResponse = UserResponse.model_validate(db_user) - if "text/html" in accept_header: - links = generate_subscription( - user=db_user, config_format="v2ray" - ).split() - return HTMLResponse( - render_template( - SUBSCRIPTION_PAGE_TEMPLATE, {"user": user, "links": links} - ) - ) + crud.update_user_sub(db, db_user, user_agent) + + subscription_settings = SubscriptionSettings.model_validate( + db.query(Settings.subscription).first()[0] + ) response_headers = { "content-disposition": f'attachment; filename="{user.username}"', "profile-web-page-url": str(request.url), - "support-url": SUB_SUPPORT_URL, - "profile-title": encode_title(SUB_PROFILE_TITLE), - "profile-update-interval": SUB_UPDATE_INTERVAL, + "support-url": subscription_settings.support_link, + "profile-title": encode_title(subscription_settings.profile_title), + "profile-update-interval": str(subscription_settings.update_interval), "subscription-userinfo": "; ".join( f"{key}={val}" for key, val in get_subscription_user_info(user).items() @@ -65,32 +75,37 @@ def user_subscription( ), } - crud.update_user_sub(db, db_user, user_agent) - - if re.match("^([Cc]lash-verge|[Cc]lash-?[Mm]eta)", user_agent): - conf = generate_subscription(user=db_user, config_format="clash-meta") - return Response( - content=conf, media_type="text/yaml", headers=response_headers - ) - elif re.match("^([Cc]lash|[Ss]tash)", user_agent): - conf = generate_subscription(user=db_user, config_format="clash") - return Response( - content=conf, media_type="text/yaml", headers=response_headers - ) - elif re.match("^(SFA|SFI|SFM|SFT)", user_agent): - conf = generate_subscription(user=db_user, config_format="sing-box") - return Response( - content=conf, - media_type="application/json", - headers=response_headers, - ) - else: - conf = generate_subscription( - user=db_user, config_format="v2ray", as_base64=True - ) - return Response( - content=conf, media_type="text/plain", headers=response_headers - ) + 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}, + ) + ) + elif rule.result.value == "block": + return HTT + elif rule.result.value == "base64-links": + b64 = True + config_format = "links" + else: + b64 = False + config_format = rule.result.value + + conf = generate_subscription( + user=db_user, + config_format=config_format, + as_base64=b64, + ) + return Response( + content=conf, + media_type=config_mimetype[rule.result], + headers=response_headers, + ) @router.get("/{username}/{key}/info", response_model=UserResponse) @@ -112,6 +127,7 @@ def user_get_usage( @router.get("/{username}/{key}/{client_type}") def user_subscription_with_client_type( + db: DBDep, db_user: SubUserDep, request: Request, client_type: str = Path(regex="sing-box|clash-meta|clash|xray|v2ray"), @@ -122,12 +138,16 @@ def user_subscription_with_client_type( user: UserResponse = UserResponse.model_validate(db_user) + subscription_settings = SubscriptionSettings.model_validate( + db.query(Settings.subscription).first()[0] + ) + response_headers = { "content-disposition": f'attachment; filename="{user.username}"', "profile-web-page-url": str(request.url), - "support-url": SUB_SUPPORT_URL, - "profile-title": encode_title(SUB_PROFILE_TITLE), - "profile-update-interval": SUB_UPDATE_INTERVAL, + "support-url": subscription_settings.support_link, + "profile-title": encode_title(subscription_settings.profile_title), + "profile-update-interval": subscription_settings.update_interval, "subscription-userinfo": "; ".join( f"{key}={val}" for key, val in get_subscription_user_info(user).items() @@ -156,7 +176,7 @@ def user_subscription_with_client_type( elif client_type == "v2ray": conf = generate_subscription( - user=db_user, config_format="v2ray", as_base64=True + user=db_user, config_format="links", as_base64=True ) return Response( content=conf, media_type="text/plain", headers=response_headers @@ -164,6 +184,7 @@ def user_subscription_with_client_type( elif client_type == "xray": return Response( content=generate_subscription(user=db_user, config_format="xray"), + media_type="application/json", headers=response_headers, ) else: diff --git a/app/routes/system.py b/app/routes/system.py index 561cbcb..aee3cf3 100644 --- a/app/routes/system.py +++ b/app/routes/system.py @@ -1,17 +1,47 @@ from fastapi import APIRouter -from app import __version__ from app.db import crud -from app.db.models import Admin as DBAdmin, User +from app.db.models import Admin as DBAdmin, User, Settings from app.db.models import Node from app.dependencies import DBDep, AdminDep, SudoAdminDep from app.models.node import NodeStatus -from app.models.system import SystemStats, UsersStats, NodesStats, AdminsStats +from app.models.settings import SubscriptionSettings, TelegramSettings +from app.models.system import UsersStats, NodesStats, AdminsStats from app.models.user import UserStatus router = APIRouter(tags=["System"], prefix="/system") +@router.get("/settings/subscription", response_model=SubscriptionSettings) +def get_subscription_settings(db: DBDep, admin: SudoAdminDep): + return db.query(Settings.subscription).first()[0] + + +@router.put("/settings/subscription", response_model=SubscriptionSettings) +def update_subscription_settings( + db: DBDep, modifications: SubscriptionSettings, admin: SudoAdminDep +): + settings = db.query(Settings).first() + settings.subscription = modifications.model_dump(mode="json") + db.commit() + return settings.subscription + + +@router.get("/settings/telegram", response_model=TelegramSettings | None) +def get_telegram_settings(db: DBDep, admin: SudoAdminDep): + return db.query(Settings.telegram).first().telegram + + +@router.put("/settings/telegram", response_model=TelegramSettings | None) +def update_telegram_settings( + db: DBDep, new_telegram: TelegramSettings | None, admin: SudoAdminDep +): + settings = db.query(Settings.telegram).first() + settings.telegram = new_telegram + db.commit() + return settings.telegram + + @router.get("/stats/admins", response_model=AdminsStats) def get_admins_stats(db: DBDep, admin: SudoAdminDep): return AdminsStats(total=db.query(DBAdmin).count()) diff --git a/app/utils/share.py b/app/utils/share.py index 2e6d6b0..8522186 100644 --- a/app/utils/share.py +++ b/app/utils/share.py @@ -43,7 +43,7 @@ STATUS_EMOJIS = { } subscription_handlers = { - "v2ray": LinksConfig, + "links": LinksConfig, "xray": XrayConfig, "clash-meta": ClashMetaConfig, "clash": ClashConfig, @@ -63,7 +63,7 @@ handlers_templates = { def generate_subscription( user: "UserResponse", - config_format: Literal["v2ray", "xray", "clash-meta", "clash", "sing-box"], + config_format: Literal["links", "xray", "clash-meta", "clash", "sing-box"], as_base64: bool = False, ) -> str: diff --git a/dashboard/package.json b/dashboard/package.json index c39f670..c298722 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -21,6 +21,10 @@ "dependencies": { "@chbphone55/pretty-bytes": "^1.0.0", "@codemirror/language": "^6.10.2", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^7.0.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@heroicons/react": "^2.1.3", "@hookform/resolvers": "^3.4.2", "@kodingdotninja/use-tailwind-breakpoint": "^0.0.5", diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index e3fbd09..ff94f22 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -11,6 +11,18 @@ dependencies: '@codemirror/language': specifier: ^6.10.2 version: 6.10.2 + '@dnd-kit/core': + specifier: ^6.1.0 + version: 6.1.0(react-dom@18.3.1)(react@18.3.1) + '@dnd-kit/modifiers': + specifier: ^7.0.0 + version: 7.0.0(@dnd-kit/core@6.1.0)(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^8.0.0 + version: 8.0.0(@dnd-kit/core@6.1.0)(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) '@heroicons/react': specifier: ^2.1.3 version: 2.1.3(react@18.3.1) @@ -1717,6 +1729,61 @@ packages: engines: {node: '>=10.0.0'} dev: true + /@dnd-kit/accessibility@3.1.0(react@18.3.1): + resolution: {integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==} + peerDependencies: + react: '>=16.8.0' + dependencies: + react: 18.3.1 + tslib: 2.6.2 + dev: false + + /@dnd-kit/core@6.1.0(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@dnd-kit/accessibility': 3.1.0(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.6.2 + dev: false + + /@dnd-kit/modifiers@7.0.0(@dnd-kit/core@6.1.0)(react@18.3.1): + resolution: {integrity: sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==} + peerDependencies: + '@dnd-kit/core': ^6.1.0 + react: '>=16.8.0' + dependencies: + '@dnd-kit/core': 6.1.0(react-dom@18.3.1)(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.6.2 + dev: false + + /@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.1.0)(react@18.3.1): + resolution: {integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==} + peerDependencies: + '@dnd-kit/core': ^6.1.0 + react: '>=16.8.0' + dependencies: + '@dnd-kit/core': 6.1.0(react-dom@18.3.1)(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.6.2 + dev: false + + /@dnd-kit/utilities@3.2.2(react@18.3.1): + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + dependencies: + react: 18.3.1 + tslib: 2.6.2 + dev: false + /@emotion/babel-plugin@11.11.0: resolution: {integrity: sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==} dependencies: diff --git a/dashboard/public/locales/en.json b/dashboard/public/locales/en.json index 7fb1fea..a41a8a2 100644 --- a/dashboard/public/locales/en.json +++ b/dashboard/public/locales/en.json @@ -51,6 +51,8 @@ "advanced-options": "Advanced Options", "language": "Language", "theme": "Theme", + "pattern": "Pattern", + "result": "Result", "support-us": { "title": "Support Us", "desc": "Marzneshin is free and open-source software (FOSS), join us and show your support by donating.", @@ -58,6 +60,20 @@ }, "page": { "settings": { + "subscription-settings": { + "url-prefix": "URL Prefix", + "profile-title": "Profile Title", + "support-link": "Support Link", + "update-interval": "Update Interval", + "add-rule": "Add Rule", + "title": "Subscription Settings", + "subscription-title": "Subscription Rules", + "subscription-desc": "Add your subscription rules in order of priority.", + "alert": { + "title": "No rules", + "desc": "No rules have been defined for subscription" + } + }, "certificate": { "copy": "Copy Certificate", "error": "Certificate Unavailable", diff --git a/dashboard/src/components/ui/index.ts b/dashboard/src/components/ui/index.ts index 4a47b94..b66074a 100644 --- a/dashboard/src/components/ui/index.ts +++ b/dashboard/src/components/ui/index.ts @@ -33,3 +33,5 @@ export * from "./textarea"; export * from "./radio-group"; export * from "./accordion"; export * from "./command"; +export * from "./sortable"; +export * from "./skeleton"; diff --git a/dashboard/src/components/ui/skeleton.tsx b/dashboard/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..72f91f5 --- /dev/null +++ b/dashboard/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@marzneshin/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/dashboard/src/components/ui/sortable/context.ts b/dashboard/src/components/ui/sortable/context.ts new file mode 100644 index 0000000..0836f76 --- /dev/null +++ b/dashboard/src/components/ui/sortable/context.ts @@ -0,0 +1,14 @@ +import type { + DraggableSyntheticListeners, +} from "@dnd-kit/core" +import React from "react"; + +interface SortableItemContextProps { + attributes: React.HTMLAttributes + listeners: DraggableSyntheticListeners | undefined +} + +export const SortableItemContext = React.createContext({ + attributes: {}, + listeners: undefined, +}) diff --git a/dashboard/src/components/ui/sortable/drag-handle.tsx b/dashboard/src/components/ui/sortable/drag-handle.tsx new file mode 100644 index 0000000..1b9e15b --- /dev/null +++ b/dashboard/src/components/ui/sortable/drag-handle.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { Button, type ButtonProps } from "@marzneshin/components" +import { useSortableItem } from "./use-sortable-item"; +import { composeRefs, cn } from "@marzneshin/utils"; + +interface SortableDragHandleProps extends ButtonProps { + withHandle?: boolean +} + +export const SortableDragHandle = React.forwardRef< + HTMLButtonElement, + SortableDragHandleProps +>(({ className, ...props }, ref) => { + const { attributes, listeners } = useSortableItem() + + return ( + + + + + + ) +} diff --git a/dashboard/src/features/subscription-settings/form/index.ts b/dashboard/src/features/subscription-settings/form/index.ts new file mode 100644 index 0000000..883c95b --- /dev/null +++ b/dashboard/src/features/subscription-settings/form/index.ts @@ -0,0 +1,3 @@ +export * from "./schema" +export * from "./rule-item" +export * from "./form" diff --git a/dashboard/src/features/subscription-settings/form/no-rules-alert.tsx b/dashboard/src/features/subscription-settings/form/no-rules-alert.tsx new file mode 100644 index 0000000..3c74e6f --- /dev/null +++ b/dashboard/src/features/subscription-settings/form/no-rules-alert.tsx @@ -0,0 +1,20 @@ +import { + Alert, + AlertDescription, + AlertTitle, +} from "@marzneshin/components"; +import { Info } from 'lucide-react'; +import { useTranslation } from "react-i18next"; + +export const NoRulesAlert = () => { + const { t } = useTranslation() + return ( + + + {t('page.settings.subscription-settings.alert.title')} + + {t('page.settings.subscription-settings.alert.desc')} + + + ) +} diff --git a/dashboard/src/features/subscription-settings/form/overlay.tsx b/dashboard/src/features/subscription-settings/form/overlay.tsx new file mode 100644 index 0000000..b13c339 --- /dev/null +++ b/dashboard/src/features/subscription-settings/form/overlay.tsx @@ -0,0 +1,12 @@ +import { + Skeleton, +} from "@marzneshin/components"; + +export const Overlay = () => ( +
+ + + + +
+) diff --git a/dashboard/src/features/subscription-settings/form/rule-item.tsx b/dashboard/src/features/subscription-settings/form/rule-item.tsx new file mode 100644 index 0000000..917b5c7 --- /dev/null +++ b/dashboard/src/features/subscription-settings/form/rule-item.tsx @@ -0,0 +1,103 @@ +import { + Button, + FormControl, + FormField, + FormItem, + Input, + SortableDragHandle, + SortableItem, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@marzneshin/components"; +import { + Schema +} from "./schema" +import { + useFormContext, + FieldArrayWithId, + UseFieldArrayRemove +} from "react-hook-form"; +import { DragHandleDots2Icon, TrashIcon } from "@radix-ui/react-icons" + +interface RuleItemProps { + index: number; + field: FieldArrayWithId; + onRemove: UseFieldArrayRemove; +} + +export const RuleItem = ({ index, field, onRemove: remove }: RuleItemProps) => { + const form = useFormContext(); + return ( + +
+ ( + + +
+ +
+
+
+ )} + /> + ( + + + + )} + /> + + + +
+
+ ) +} diff --git a/dashboard/src/features/subscription-settings/form/schema.ts b/dashboard/src/features/subscription-settings/form/schema.ts new file mode 100644 index 0000000..882578d --- /dev/null +++ b/dashboard/src/features/subscription-settings/form/schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + + +export const schema = z.object({ + rules: z.array( + z.object({ + pattern: z.string(), + result: z.enum(["xray", "v2ray", "sing-box", "clash", "clash-meta", "block", "links", "base64-links", "template"]), + }) + ), + url_prefix: z.string().default(""), + profile_title: z.string().default(""), + support_link: z.string().default(""), + update_interval: z.coerce.number().int().default(0), +}) + +export type Schema = z.infer diff --git a/dashboard/src/features/subscription-settings/index.ts b/dashboard/src/features/subscription-settings/index.ts new file mode 100644 index 0000000..2000bd0 --- /dev/null +++ b/dashboard/src/features/subscription-settings/index.ts @@ -0,0 +1,4 @@ +export * from "./form" +export * from "./services" +export * from "./subscription-rule.type" +export * from "./widget" diff --git a/dashboard/src/features/subscription-settings/services/index.ts b/dashboard/src/features/subscription-settings/services/index.ts new file mode 100644 index 0000000..3a16e0a --- /dev/null +++ b/dashboard/src/features/subscription-settings/services/index.ts @@ -0,0 +1,2 @@ +export * from "./subscription-settings.query" +export * from "./subscription-settings.mutate" diff --git a/dashboard/src/features/subscription-settings/services/subscription-settings.mutate.ts b/dashboard/src/features/subscription-settings/services/subscription-settings.mutate.ts new file mode 100644 index 0000000..8d2c2ad --- /dev/null +++ b/dashboard/src/features/subscription-settings/services/subscription-settings.mutate.ts @@ -0,0 +1,39 @@ +import { NodeType } from "@marzneshin/features/nodes"; +import { useMutation } from "@tanstack/react-query"; +import { fetch, queryClient } from "@marzneshin/utils"; +import { toast } from "sonner"; +import i18n from "@marzneshin/features/i18n"; +import { + subscriptionSettingsQueryKey, + SubscriptionSettingsType +} from "@marzneshin/features/subscription-settings"; + +export async function updateSubscriptionSettings(settings: SubscriptionSettingsType): Promise { + return fetch("/system/settings/subscription", { method: 'put', body: settings }).then((node) => { + return node; + }); +} + +const handleError = () => { + toast.error( + i18n.t('events.update.error'), + ) +} + +const handleSuccess = () => { + toast.success( + i18n.t('events.update.success.title', { name: "Subscription Settings" }), + { + description: i18n.t('events.update.success.desc') + }) + queryClient.invalidateQueries({ queryKey: [subscriptionSettingsQueryKey] }) +} + +export const useSubscriptionSettingsMutation = () => { + return useMutation({ + mutationKey: subscriptionSettingsQueryKey, + mutationFn: updateSubscriptionSettings, + onError: handleError, + onSuccess: handleSuccess, + }) +} diff --git a/dashboard/src/features/subscription-settings/services/subscription-settings.query.ts b/dashboard/src/features/subscription-settings/services/subscription-settings.query.ts new file mode 100644 index 0000000..e2785c4 --- /dev/null +++ b/dashboard/src/features/subscription-settings/services/subscription-settings.query.ts @@ -0,0 +1,23 @@ +import { SubscriptionSettingsType } from "@marzneshin/features/subscription-settings"; +import { useQuery } from "@tanstack/react-query"; +import { fetch } from "@marzneshin/utils"; + +export async function fetchSubscriptionSettings(): Promise { + return fetch(`/system/settings/subscription`); +} + +export const subscriptionSettingsQueryKey = ["system", "settings", "subscription"] + +export const useSubscriptionSettingsQuery = () => { + return useQuery({ + queryKey: subscriptionSettingsQueryKey, + queryFn: fetchSubscriptionSettings, + initialData: { + url_prefix: "", + profile_title: "", + support_link: "", + update_interval: 0, + rules: [], + }, + }); +}; diff --git a/dashboard/src/features/subscription-settings/subscription-rule.type.ts b/dashboard/src/features/subscription-settings/subscription-rule.type.ts new file mode 100644 index 0000000..962cbe6 --- /dev/null +++ b/dashboard/src/features/subscription-settings/subscription-rule.type.ts @@ -0,0 +1,15 @@ +export type SubscriptionRuleResultType = + "xray" | "v2ray" | "sing-box" | "clash" | "clash-meta" | "block" | "links" | "base64-links" | "template" + +export type SubscriptionRuleType = { + pattern: string, + result: SubscriptionRuleResultType +} + +export type SubscriptionSettingsType = { + url_prefix: string; + profile_title: string; + support_link: string; + update_interval: number; + rules: SubscriptionRuleType[]; +} diff --git a/dashboard/src/features/subscription-settings/widget/index.tsx b/dashboard/src/features/subscription-settings/widget/index.tsx new file mode 100644 index 0000000..1fd46de --- /dev/null +++ b/dashboard/src/features/subscription-settings/widget/index.tsx @@ -0,0 +1,22 @@ +import { + SectionWidget +} from "@marzneshin/components"; +import { SubscriptionRulesForm } from "@marzneshin/features/subscription-settings"; +import { useTranslation } from "react-i18next"; + +export const SubscriptionSettingsWidget = () => { + const { t } = useTranslation() + return ( + +

+ {t("page.settings.subscription-settings.title")} +

+
+ } + > + + + ) +} diff --git a/dashboard/src/routes/_dashboard/settings.lazy.tsx b/dashboard/src/routes/_dashboard/settings.lazy.tsx index 331ffd4..47acf3d 100644 --- a/dashboard/src/routes/_dashboard/settings.lazy.tsx +++ b/dashboard/src/routes/_dashboard/settings.lazy.tsx @@ -1,7 +1,8 @@ -import { Card, CardContent, CardHeader, CardTitle, Page } from '@marzneshin/components' -import { CertificateButton } from '@marzneshin/features/settings' -import { createFileRoute } from '@tanstack/react-router' +import { Card, CardContent, CardHeader, CardTitle, Page, VStack } from '@marzneshin/components' +import { CertificateWidget, ConfigurationWidget } from '@marzneshin/features/settings'; +import { SubscriptionSettingsWidget } from '@marzneshin/features/subscription-settings'; +import { createFileRoute } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next' export const Settings = () => { @@ -9,13 +10,17 @@ export const Settings = () => { return ( - - - - {t('settings')} - - - + + + {t('settings')} + + + + + + + + diff --git a/dashboard/src/utils/compose-refs.ts b/dashboard/src/utils/compose-refs.ts new file mode 100644 index 0000000..f89676a --- /dev/null +++ b/dashboard/src/utils/compose-refs.ts @@ -0,0 +1,36 @@ +// @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/composeRefs.tsx + +import * as React from "react" + +type PossibleRef = React.Ref | undefined + +/** + * Set a given ref to a given value + * This utility takes care of different types of refs: callback refs and RefObject(s) + */ +function setRef(ref: PossibleRef, value: T) { + if (typeof ref === "function") { + ref(value) + } else if (ref !== null && ref !== undefined) { + (ref as React.MutableRefObject).current = value + } +} + +/** + * A utility to compose multiple refs together + * Accepts callback refs and RefObject(s) + */ +function composeRefs(...refs: PossibleRef[]) { + return (node: T) => refs.forEach((ref) => setRef(ref, node)) +} + +/** + * A custom hook that composes multiple refs + * Accepts callback refs and RefObject(s) + */ +function useComposedRefs(...refs: PossibleRef[]) { + // eslint-disable-next-line react-hooks/exhaustive-deps + return React.useCallback(composeRefs(...refs), refs) +} + +export { composeRefs, useComposedRefs } diff --git a/dashboard/src/utils/index.ts b/dashboard/src/utils/index.ts index d8e6241..fdedd44 100644 --- a/dashboard/src/utils/index.ts +++ b/dashboard/src/utils/index.ts @@ -5,3 +5,4 @@ export * from './query-client' export * from './subscription-link' export * from './is-online' export * from './project-info' +export * from './compose-refs'