Compare commits

..

2 Commits

Author SHA1 Message Date
vikrantgupta25
065c483db2 fix(authz): address review comments and add role escalation guard
- Move typeableRegistry to separate file typeable_registry.go
- Remove RegisterTypeable embedding from Module interface — registry
  is a standalone concern passed directly to authz callback
- Add privilege escalation guard in setRole: verify the caller holds
  the role being assigned before granting it to a service account

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 02:05:16 +05:30
vikrantgupta25
61a0ba4e54 feat(authz): add resource-level FGA for service accounts
Service accounts were already FGA subjects (assigned roles via tuples),
but service account management routes used hardcoded AdminAccess. This
switches all service account routes to the Check middleware pattern so
that who can create/read/update/delete service accounts is governed by
FGA tuples, matching the dashboard meta-resource pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 01:46:39 +05:30
60 changed files with 301 additions and 3011 deletions

View File

@@ -92,7 +92,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ ...authz.RegisterTypeable) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore)
if err != nil {
return nil, err

View File

@@ -137,12 +137,12 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return authNs, nil
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, licensing licensing.Licensing, dashboardModule dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
func(ctx context.Context, sqlstore sqlstore.SQLStore, licensing licensing.Licensing, registry ...authz.RegisterTypeable) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore)
if err != nil {
return nil, err
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, dashboardModule), nil
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, registry...), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)

View File

@@ -286,8 +286,6 @@
// Prevents useStore.getState() - export standalone actions instead
"signoz/no-navigator-clipboard": "error",
// Prevents navigator.clipboard - use useCopyToClipboard hook instead (disabled in tests via override)
"signoz/no-raw-absolute-path": "warn",
// Prevents window.open(path), window.location.origin + path, window.location.href = path
"no-restricted-imports": [
"error",
{
@@ -599,9 +597,8 @@
"rules": {
"import/first": "off",
// Should ignore due to mocks
"signoz/no-navigator-clipboard": "off",
// Tests can use navigator.clipboard directly,
"signoz/no-raw-absolute-path":"off"
"signoz/no-navigator-clipboard": "off"
// Tests can use navigator.clipboard directly
}
},
{

View File

@@ -1,4 +1,3 @@
// oxlint-disable-next-line typescript/no-require-imports
const path = require('path');
module.exports = {

View File

@@ -2,7 +2,6 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="[[.BaseHref]]" />
<meta
http-equiv="Cache-Control"
content="no-cache, no-store, must-revalidate, max-age: 0"
@@ -60,7 +59,7 @@
<meta data-react-helmet="true" name="docusaurus_locale" content="en" />
<meta data-react-helmet="true" name="docusaurus_tag" content="default" />
<meta name="robots" content="noindex" />
<link data-react-helmet="true" rel="shortcut icon" href="favicon.ico" />
<link data-react-helmet="true" rel="shortcut icon" href="/favicon.ico" />
</head>
<body data-theme="default">
<script>
@@ -137,7 +136,7 @@
})(document, 'script');
}
</script>
<link rel="stylesheet" href="css/uPlot.min.css" />
<link rel="stylesheet" href="/css/uPlot.min.css" />
<script type="module" src="./src/index.tsx"></script>
</body>
</html>

View File

@@ -1,152 +0,0 @@
/**
* Rule: no-raw-absolute-path
*
* Catches patterns that break at runtime when the app is served from a
* sub-path (e.g. /signoz/):
*
* 1. window.open(path, '_blank')
* → use openInNewTab(path) which calls withBasePath internally
*
* 2. window.location.origin + path / `${window.location.origin}${path}`
* → use getAbsoluteUrl(path)
*
* 3. frontendBaseUrl: window.location.origin (bare origin usage)
* → use getBaseUrl() to include the base path
*
* 4. window.location.href = path
* → use withBasePath(path) or navigate() for internal navigation
*
* External URLs (first arg starts with "http") are explicitly allowed.
*/
function isOriginAccess(node) {
return (
node.type === 'MemberExpression' &&
!node.computed &&
node.property.name === 'origin' &&
node.object.type === 'MemberExpression' &&
!node.object.computed &&
node.object.property.name === 'location' &&
node.object.object.type === 'Identifier' &&
node.object.object.name === 'window'
);
}
function isHrefAccess(node) {
return (
node.type === 'MemberExpression' &&
!node.computed &&
node.property.name === 'href' &&
node.object.type === 'MemberExpression' &&
!node.object.computed &&
node.object.property.name === 'location' &&
node.object.object.type === 'Identifier' &&
node.object.object.name === 'window'
);
}
function isExternalUrl(node) {
if (node.type === 'Literal' && typeof node.value === 'string') {
return node.value.startsWith('http://') || node.value.startsWith('https://');
}
if (node.type === 'TemplateLiteral' && node.quasis.length > 0) {
const raw = node.quasis[0].value.raw;
return raw.startsWith('http://') || raw.startsWith('https://');
}
return false;
}
// window.open(withBasePath(x)) and window.open(getAbsoluteUrl(x)) are already safe.
function isSafeHelperCall(node) {
return (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
(node.callee.name === 'withBasePath' || node.callee.name === 'getAbsoluteUrl')
);
}
export default {
meta: {
type: 'suggestion',
docs: {
description:
'Disallow raw window.open and origin-concatenation patterns that miss the runtime base path',
category: 'Base Path Safety',
},
schema: [],
messages: {
windowOpen:
'Use openInNewTab(path) instead of window.open(path, "_blank") — openInNewTab prepends the base path automatically.',
originConcat:
'Use getAbsoluteUrl(path) instead of window.location.origin + path — getAbsoluteUrl prepends the base path automatically.',
originDirect:
'Use getBaseUrl() instead of window.location.origin — getBaseUrl includes the base path.',
hrefAssign:
'Use withBasePath(path) or navigate() instead of window.location.href = path — ensures the base path is included.',
},
},
create(context) {
return {
// window.open(path, ...) — allow only external first-arg URLs
CallExpression(node) {
const { callee, arguments: args } = node;
if (
callee.type !== 'MemberExpression' ||
callee.object.type !== 'Identifier' ||
callee.object.name !== 'window' ||
callee.property.name !== 'open'
)
{return;}
if (args.length === 0) {return;}
if (isExternalUrl(args[0])) {return;}
if (isSafeHelperCall(args[0])) {return;}
context.report({ node, messageId: 'windowOpen' });
},
// window.location.origin + path
BinaryExpression(node) {
if (node.operator !== '+') {return;}
if (isOriginAccess(node.left) || isOriginAccess(node.right)) {
context.report({ node, messageId: 'originConcat' });
}
},
// `${window.location.origin}${path}`
TemplateLiteral(node) {
if (node.expressions.some(isOriginAccess)) {
context.report({ node, messageId: 'originConcat' });
}
},
// window.location.origin used directly (not in concatenation)
// Catches: frontendBaseUrl: window.location.origin
MemberExpression(node) {
if (!isOriginAccess(node)) {return;}
const parent = node.parent;
// Skip if parent is BinaryExpression with + (handled by BinaryExpression visitor)
if (parent.type === 'BinaryExpression' && parent.operator === '+') {return;}
// Skip if inside TemplateLiteral (handled by TemplateLiteral visitor)
if (parent.type === 'TemplateLiteral') {return;}
context.report({ node, messageId: 'originDirect' });
},
// window.location.href = path
AssignmentExpression(node) {
if (node.operator !== '=') {return;}
if (!isHrefAccess(node.left)) {return;}
// Allow external URLs
if (isExternalUrl(node.right)) {return;}
// Allow safe helper calls
if (isSafeHelperCall(node.right)) {return;}
context.report({ node, messageId: 'hrefAssign' });
},
};
},
};

View File

@@ -8,7 +8,6 @@
import noZustandGetStateInHooks from './rules/no-zustand-getstate-in-hooks.mjs';
import noNavigatorClipboard from './rules/no-navigator-clipboard.mjs';
import noUnsupportedAssetPattern from './rules/no-unsupported-asset-pattern.mjs';
import noRawAbsolutePath from './rules/no-raw-absolute-path.mjs';
export default {
meta: {
@@ -18,6 +17,5 @@ export default {
'no-zustand-getstate-in-hooks': noZustandGetStateInHooks,
'no-navigator-clipboard': noNavigatorClipboard,
'no-unsupported-asset-pattern': noUnsupportedAssetPattern,
'no-raw-absolute-path': noRawAbsolutePath,
},
};

View File

@@ -2,7 +2,6 @@ import { initReactI18next } from 'react-i18next';
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
import { getBasePath } from 'utils/basePath';
import cacheBursting from '../../i18n-translations-hash.json';
@@ -25,7 +24,7 @@ i18n
const ns = namespace[0];
const pathkey = `/${language}/${ns}`;
const hash = cacheBursting[pathkey as keyof typeof cacheBursting] || '';
return `${getBasePath()}locales/${language}/${namespace}.json?h=${hash}`;
return `/locales/${language}/${namespace}.json?h=${hash}`;
},
},
react: {

View File

@@ -1,6 +1,5 @@
import {
interceptorRejected,
interceptorsRequestBasePath,
interceptorsRequestResponse,
interceptorsResponse,
} from 'api';
@@ -18,7 +17,6 @@ export const GeneratedAPIInstance = <T>(
return generatedAPIAxiosInstance({ ...config }).then(({ data }) => data);
};
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
generatedAPIAxiosInstance.interceptors.response.use(
interceptorsResponse,

View File

@@ -11,7 +11,6 @@ import axios, {
import { ENVIRONMENT } from 'constants/env';
import { Events } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { getBasePath } from 'utils/basePath';
import { eventEmitter } from 'utils/getEventEmitter';
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
@@ -68,39 +67,6 @@ export const interceptorsRequestResponse = (
return value;
};
// Strips the leading '/' from path and joins with base — idempotent if already prefixed.
// e.g. prependBase('/signoz/', '/api/v1/') → '/signoz/api/v1/'
function prependBase(base: string, path: string): string {
return path.startsWith(base) ? path : base + path.slice(1);
}
// Prepends the runtime base path to outgoing requests so API calls work under
// a URL prefix (e.g. /signoz/api/v1/…). No-op for root deployments and dev
// (dev baseURL is a full http:// URL, not an absolute path).
export const interceptorsRequestBasePath = (
value: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig => {
const basePath = getBasePath();
if (basePath === '/') {
return value;
}
if (value.baseURL?.startsWith('/')) {
// Production relative baseURL: '/api/v1/' → '/signoz/api/v1/'
value.baseURL = prependBase(basePath, value.baseURL);
} else if (value.baseURL?.startsWith('http')) {
// Dev absolute baseURL (VITE_FRONTEND_API_ENDPOINT): 'https://host/api/v1/' → 'https://host/signoz/api/v1/'
const url = new URL(value.baseURL);
url.pathname = prependBase(basePath, url.pathname);
value.baseURL = url.toString();
} else if (!value.baseURL && value.url?.startsWith('/')) {
// Orval-generated client (empty baseURL, path in url): '/api/signoz/v1/rules' → '/signoz/api/signoz/v1/rules'
value.url = prependBase(basePath, value.url);
}
return value;
};
export const interceptorRejected = async (
value: AxiosResponse<any>,
): Promise<AxiosResponse<any>> => {
@@ -167,7 +133,6 @@ const instance = axios.create({
});
instance.interceptors.request.use(interceptorsRequestResponse);
instance.interceptors.request.use(interceptorsRequestBasePath);
instance.interceptors.response.use(interceptorsResponse, interceptorRejected);
export const AxiosAlertManagerInstance = axios.create({
@@ -182,7 +147,6 @@ ApiV2Instance.interceptors.response.use(
interceptorRejected,
);
ApiV2Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV2Instance.interceptors.request.use(interceptorsRequestBasePath);
// axios V3
export const ApiV3Instance = axios.create({
@@ -194,7 +158,6 @@ ApiV3Instance.interceptors.response.use(
interceptorRejected,
);
ApiV3Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV3Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios V4
@@ -207,7 +170,6 @@ ApiV4Instance.interceptors.response.use(
interceptorRejected,
);
ApiV4Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV4Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios V5
@@ -220,7 +182,6 @@ ApiV5Instance.interceptors.response.use(
interceptorRejected,
);
ApiV5Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV5Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios Base
@@ -233,7 +194,6 @@ LogEventAxiosInstance.interceptors.response.use(
interceptorRejectedBase,
);
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
//
AxiosAlertManagerInstance.interceptors.response.use(
@@ -241,7 +201,6 @@ AxiosAlertManagerInstance.interceptors.response.use(
interceptorRejected,
);
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestResponse);
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestBasePath);
export { apiV1 };
export default instance;

View File

@@ -12,7 +12,6 @@ import { AppState } from 'store/reducers';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { withBasePath } from 'utils/basePath';
export interface NavigateToExplorerProps {
filters: TagFilterItem[];
@@ -134,7 +133,7 @@ export function useNavigateToExplorer(): (
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
window.open(newExplorerPath, sameTab ? '_self' : '_blank');
},
[
prepareQuery,

View File

@@ -13,7 +13,6 @@ import GetMinMax from 'lib/getMinMax';
import { Check, Info, Link2 } from 'lucide-react';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getAbsoluteUrl } from 'utils/basePath';
const routesToBeSharedWithTime = [
ROUTES.LOGS_EXPLORER,
@@ -81,13 +80,17 @@ function ShareURLModal(): JSX.Element {
urlQuery.delete(QueryParams.relativeTime);
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
} else {
urlQuery.delete(QueryParams.startTime);
urlQuery.delete(QueryParams.endTime);
urlQuery.set(QueryParams.relativeTime, selectedTime);
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
}
}

View File

@@ -9,7 +9,6 @@ import {
} from 'container/ApiMonitoring/utils';
import { UnfoldVertical } from 'lucide-react';
import { SuccessResponse } from 'types/api';
import { openInNewTab } from 'utils/navigation';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
@@ -95,14 +94,20 @@ function DependentServices({
}}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
const serviceName =
record.serviceData.serviceName && record.serviceData.serviceName !== '-'
? record.serviceData.serviceName
: '';
const url = new URL(
`/services/${
record.serviceData.serviceName &&
record.serviceData.serviceName !== '-'
? record.serviceData.serviceName
: ''
}`,
window.location.origin,
);
const urlQuery = new URLSearchParams();
urlQuery.set(QueryParams.startTime, timeRange.startTime.toString());
urlQuery.set(QueryParams.endTime, timeRange.endTime.toString());
openInNewTab(`/services/${serviceName}?${urlQuery.toString()}`);
url.search = urlQuery.toString();
window.open(url.toString(), '_blank');
},
className: 'clickable-row',
})}

View File

@@ -14,7 +14,6 @@ import { IUser } from 'providers/App/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import { ROUTING_POLICIES_ROUTE } from './constants';
import { RoutingPolicyBannerProps } from './types';
@@ -388,7 +387,7 @@ export function NotificationChannelsNotFoundContent({
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
openInNewTab(ROUTES.CHANNELS_NEW);
window.open(ROUTES.CHANNELS_NEW, '_blank');
}}
>
here.

View File

@@ -15,7 +15,6 @@ import { AlertDef, Labels } from 'types/api/alerts/def';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import { openInNewTab } from 'utils/navigation';
import { popupContainer } from 'utils/selectPopupContainer';
import ChannelSelect from './ChannelSelect';
@@ -88,7 +87,7 @@ function BasicInfo({
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
ruleId: isNewRule ? 0 : alertDef?.id,
});
openInNewTab(ROUTES.CHANNELS_NEW);
window.open(ROUTES.CHANNELS_NEW, '_blank');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hasLoggedEvent = useRef(false);

View File

@@ -83,8 +83,6 @@ import {
} from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { isModifierKeyPressed } from 'utils/app';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import dashboardsUrl from '@/assets/Icons/dashboards.svg';
@@ -459,7 +457,7 @@ function DashboardsList(): JSX.Element {
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
openInNewTab(getLink());
window.open(getLink(), '_blank');
}}
>
Open in New Tab
@@ -471,7 +469,7 @@ function DashboardsList(): JSX.Element {
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(getAbsoluteUrl(getLink()));
setCopy(`${window.location.origin}${getLink()}`);
}}
>
Copy Link

View File

@@ -1,7 +1,6 @@
import { LockFilled } from '@ant-design/icons';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { openInNewTab } from 'utils/navigation';
import { Data } from '../DashboardsList';
import { TableLinkText } from './styles';
@@ -13,7 +12,7 @@ function Name(name: Data['name'], data: Data): JSX.Element {
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
if (event.metaKey || event.ctrlKey) {
openInNewTab(getLink());
window.open(getLink(), '_blank');
} else {
history.push(getLink());
}

View File

@@ -17,7 +17,6 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { withBasePath } from 'utils/basePath';
import { useContextLogData } from './useContextLogData';
@@ -117,7 +116,7 @@ function ContextLogRenderer({
);
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
window.open(withBasePath(link), '_blank', 'noopener,noreferrer');
window.open(link, '_blank', 'noopener,noreferrer');
},
[query, urlQuery],
);

View File

@@ -34,7 +34,6 @@ import { SET_DETAILED_LOG_DATA } from 'types/actions/logs';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { openInNewTab } from 'utils/navigation';
import { ActionItemProps } from './ActionItem';
import FieldRenderer from './FieldRenderer';
@@ -192,7 +191,7 @@ function TableView({
if (event.ctrlKey || event.metaKey) {
// open the trace in new tab
openInNewTab(route);
window.open(route, '_blank');
} else {
history.push(route);
}

View File

@@ -34,7 +34,6 @@ import ROUTES from 'constants/routes';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDragColumns from 'hooks/useDragColumns';
import { getAbsoluteUrl } from 'utils/basePath';
import { infinityDefaultStyles } from '../InfinityTableView/config';
import { TanStackTableStyled } from '../InfinityTableView/styles';
@@ -240,7 +239,7 @@ const TanStackTableView = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
urlQuery.delete(QueryParams.activeLogId);
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
setCopy(link);
toast.success('Copied to clipboard', { position: 'top-right' });

View File

@@ -1,6 +1,5 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { withBasePath } from 'utils/basePath';
import { TopOperationList } from './TopOperationsTable';
import { NavigateToTraceProps } from './types';
@@ -38,7 +37,7 @@ export const navigateToTrace = ({
}=${JSONCompositeQuery}`;
if (openInNewTab) {
window.open(withBasePath(newTraceExplorerPath), '_blank');
window.open(newTraceExplorerPath, '_blank');
} else {
safeNavigate(newTraceExplorerPath);
}

View File

@@ -14,7 +14,6 @@ import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { openInNewTab } from 'utils/navigation';
import { ContextMenuItem } from './contextConfig';
import { getDataLinks } from './dataLinksUtils';
@@ -116,7 +115,7 @@ const useBaseAggregateOptions = ({
key={id}
icon={<LinkOutlined />}
onClick={(): void => {
openInNewTab(url);
window.open(url, '_blank');
onClose?.();
}}
>

View File

@@ -14,7 +14,6 @@ import { ModalTitle } from 'container/PipelinePage/PipelineListsView/styles';
import { Check, Loader, X } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import { INITIAL_ROUTING_POLICY_DETAILS_FORM_STATE } from './constants';
import {
@@ -77,7 +76,7 @@ function RoutingPolicyDetails({
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
openInNewTab(ROUTES.CHANNELS_NEW);
window.open(ROUTES.CHANNELS_NEW, '_blank');
}}
>
here.

View File

@@ -28,7 +28,6 @@ import {
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { v4 as uuid } from 'uuid';
import noDataUrl from '@/assets/Icons/no-data.svg';
@@ -144,7 +143,7 @@ function SpanLogs({
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;
openInNewTab(url);
window.open(url, '_blank');
},
[
isLogSpanRelated,

View File

@@ -17,7 +17,6 @@ import { BarChart2, Compass, X } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Span } from 'types/api/trace/getTraceV2';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import { getAbsoluteUrl } from 'utils/basePath';
import { RelatedSignalsViews } from '../constants';
import SpanLogs from '../SpanLogs/SpanLogs';
@@ -159,7 +158,9 @@ function SpanRelatedSignals({
searchParams.set(QueryParams.endTime, endTimeMs.toString());
window.open(
getAbsoluteUrl(`${ROUTES.LOGS_EXPLORER}?${searchParams.toString()}`),
`${window.location.origin}${
ROUTES.LOGS_EXPLORER
}?${searchParams.toString()}`,
'_blank',
'noopener,noreferrer',
);

View File

@@ -31,7 +31,6 @@ import {
UPDATE_SPANS_AGGREGATE_PAGE_SIZE,
} from 'types/actions/trace';
import { TraceReducer } from 'types/reducer/trace';
import { openInNewTab } from 'utils/navigation';
import { v4 } from 'uuid';
dayjs.extend(duration);
@@ -215,7 +214,7 @@ function TraceTable(): JSX.Element {
event.preventDefault();
event.stopPropagation();
if (event.metaKey || event.ctrlKey) {
openInNewTab(getLink(record));
window.open(getLink(record), '_blank');
} else {
history.push(getLink(record));
}

View File

@@ -28,7 +28,6 @@ import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { openInNewTab } from 'utils/navigation';
import './TracesTableComponent.styles.scss';
@@ -87,7 +86,7 @@ function TracesTableComponent({
event.preventDefault();
event.stopPropagation();
if (event.metaKey || event.ctrlKey) {
openInNewTab(getTraceLink(record));
window.open(getTraceLink(record), '_blank');
} else {
history.push(getTraceLink(record));
}

View File

@@ -17,7 +17,6 @@ import useUrlQuery from 'hooks/useUrlQuery';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getAbsoluteUrl } from 'utils/basePath';
import { HIGHLIGHTED_DELAY } from './configs';
import { UseCopyLogLink } from './types';
@@ -61,7 +60,7 @@ export const useCopyLogLink = (logId?: string): UseCopyLogLink => {
urlQuery.set(QueryParams.startTime, minTime?.toString() || '');
urlQuery.set(QueryParams.endTime, maxTime?.toString() || '');
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
setCopy(link);

View File

@@ -4,7 +4,6 @@ import { useCopyToClipboard } from 'react-use';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import { Span } from 'types/api/trace/getTraceV2';
import { getAbsoluteUrl } from 'utils/basePath';
export const useCopySpanLink = (
span?: Span,
@@ -29,7 +28,7 @@ export const useCopySpanLink = (
urlQuery.set('spanId', span?.spanId);
}
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
setCopy(link);
notifications.success({

View File

@@ -1,7 +1,6 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import { cloneDeep, isEqual } from 'lodash-es';
import { withBasePath } from 'utils/basePath';
interface NavigateOptions {
replace?: boolean;
@@ -131,7 +130,7 @@ export const useSafeNavigate = (
typeof to === 'string'
? to
: `${to.pathname || location.pathname}${to.search || ''}`;
window.open(withBasePath(targetPath), '_blank');
window.open(targetPath, '_blank');
return;
}

View File

@@ -1,4 +1,3 @@
import { createBrowserHistory } from 'history';
import { getBasePath } from 'utils/basePath';
export default createBrowserHistory({ basename: getBasePath() });
export default createBrowserHistory();

View File

@@ -4,7 +4,6 @@ import ROUTES from 'constants/routes';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { Home, LifeBuoy } from 'lucide-react';
import { withBasePath } from 'utils/basePath';
import cloudUrl from '@/assets/Images/cloud.svg';
@@ -12,8 +11,8 @@ import './ErrorBoundaryFallback.styles.scss';
function ErrorBoundaryFallback(): JSX.Element {
const handleReload = (): void => {
// Hard reload resets Sentry.ErrorBoundary state; withBasePath preserves any /signoz/ prefix.
window.location.href = withBasePath(ROUTES.HOME);
// Go to home page
window.location.href = ROUTES.HOME;
};
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();

View File

@@ -19,7 +19,6 @@ import {
} from 'pages/MessagingQueues/MessagingQueuesUtils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { openInNewTab } from 'utils/navigation';
import {
convertToMilliseconds,
@@ -94,7 +93,7 @@ export function getColumns(
key={item}
className="traceid-text"
onClick={(): void => {
openInNewTab(`${ROUTES.TRACE}/${item}`);
window.open(`${ROUTES.TRACE}/${item}`, '_blank');
logEvent(`MQ Kafka: Drop Rate - traceid navigation`, {
item,
});
@@ -124,7 +123,7 @@ export function getColumns(
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
openInNewTab(`/services/${encodeURIComponent(text)}`);
window.open(`/services/${encodeURIComponent(text)}`, '_blank');
}}
>
{text}

View File

@@ -1,118 +0,0 @@
/**
* basePath is memoized at module init, so each describe block isolates the
* module with a fresh DOM state using jest.isolateModules + require.
*/
type BasePath = typeof import('../basePath');
function loadModule(href?: string): BasePath {
if (href !== undefined) {
const base = document.createElement('base');
base.setAttribute('href', href);
document.head.append(base);
}
let mod!: BasePath;
jest.isolateModules(() => {
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
mod = require('../basePath');
});
return mod;
}
afterEach(() => {
for (const el of document.head.querySelectorAll('base')) { el.remove(); }
});
describe('at basePath="/"', () => {
let m: BasePath;
beforeEach(() => {
m = loadModule('/');
});
it('getBasePath returns "/"', () => {
expect(m.getBasePath()).toBe('/');
});
it('withBasePath is a no-op for any internal path', () => {
expect(m.withBasePath('/logs')).toBe('/logs');
expect(m.withBasePath('/logs/explorer')).toBe('/logs/explorer');
});
it('withBasePath passes through external URLs', () => {
expect(m.withBasePath('https://example.com/foo')).toBe(
'https://example.com/foo',
);
});
it('getAbsoluteUrl returns origin + path', () => {
expect(m.getAbsoluteUrl('/logs')).toBe(`${window.location.origin}/logs`);
});
it('getBaseUrl returns bare origin', () => {
expect(m.getBaseUrl()).toBe(window.location.origin);
});
});
describe('at basePath="/signoz/"', () => {
let m: BasePath;
beforeEach(() => {
m = loadModule('/signoz/');
});
it('getBasePath returns "/signoz/"', () => {
expect(m.getBasePath()).toBe('/signoz/');
});
it('withBasePath prepends the prefix', () => {
expect(m.withBasePath('/logs')).toBe('/signoz/logs');
expect(m.withBasePath('/logs/explorer')).toBe('/signoz/logs/explorer');
});
it('withBasePath is idempotent — safe to call twice', () => {
expect(m.withBasePath('/signoz/logs')).toBe('/signoz/logs');
});
it('withBasePath is idempotent when path equals the prefix without trailing slash', () => {
expect(m.withBasePath('/signoz')).toBe('/signoz');
});
it('withBasePath passes through external URLs', () => {
expect(m.withBasePath('https://example.com/foo')).toBe(
'https://example.com/foo',
);
});
it('getAbsoluteUrl returns origin + prefixed path', () => {
expect(m.getAbsoluteUrl('/logs')).toBe(
`${window.location.origin}/signoz/logs`,
);
});
it('getBaseUrl returns origin + prefix without trailing slash', () => {
expect(m.getBaseUrl()).toBe(`${window.location.origin}/signoz`);
});
});
describe('no <base> tag', () => {
it('getBasePath falls back to "/"', () => {
const m = loadModule();
expect(m.getBasePath()).toBe('/');
});
});
describe('href without trailing slash', () => {
it('normalises to trailing slash', () => {
const m = loadModule('/signoz');
expect(m.getBasePath()).toBe('/signoz/');
expect(m.withBasePath('/logs')).toBe('/signoz/logs');
});
});
describe('nested prefix "/a/b/prefix/"', () => {
it('withBasePath handles arbitrary depth', () => {
const m = loadModule('/a/b/prefix/');
expect(m.withBasePath('/logs')).toBe('/a/b/prefix/logs');
expect(m.withBasePath('/a/b/prefix/logs')).toBe('/a/b/prefix/logs');
});
});

View File

@@ -1,38 +1,25 @@
import { isModifierKeyPressed } from '../app';
type NavigationModule = typeof import('../navigation');
function loadNavigationModule(href?: string): NavigationModule {
if (href !== undefined) {
const base = document.createElement('base');
base.setAttribute('href', href);
document.head.append(base);
}
let mod!: NavigationModule;
jest.isolateModules(() => {
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
mod = require('../navigation');
});
return mod;
}
const createMouseEvent = (overrides: Partial<MouseEvent> = {}): MouseEvent =>
({
metaKey: false,
ctrlKey: false,
button: 0,
...overrides,
}) as MouseEvent;
import { openInNewTab } from '../navigation';
describe('navigation utilities', () => {
const originalWindowOpen = window.open;
beforeEach(() => {
window.open = jest.fn();
});
afterEach(() => {
window.open = originalWindowOpen;
for (const el of document.head.querySelectorAll('base')) { el.remove(); }
});
describe('isModifierKeyPressed', () => {
const createMouseEvent = (overrides: Partial<MouseEvent> = {}): MouseEvent =>
({
metaKey: false,
ctrlKey: false,
button: 0,
...overrides,
} as MouseEvent);
it('returns true when metaKey is pressed (Cmd on Mac)', () => {
const event = createMouseEvent({ metaKey: true });
@@ -69,59 +56,25 @@ describe('navigation utilities', () => {
});
describe('openInNewTab', () => {
describe('at basePath="/"', () => {
let m: NavigationModule;
beforeEach(() => {
jest.spyOn(window, 'open').mockImplementation();
m = loadNavigationModule('/');
});
it('passes internal path through unchanged', () => {
m.openInNewTab('/dashboard');
expect(window.open).toHaveBeenCalledWith('/dashboard', '_blank');
});
it('passes through external URLs unchanged', () => {
m.openInNewTab('https://example.com/page');
expect(window.open).toHaveBeenCalledWith(
'https://example.com/page',
'_blank',
);
});
it('handles paths with query strings', () => {
m.openInNewTab('/alerts?tab=AlertRules&relativeTime=30m');
expect(window.open).toHaveBeenCalledWith(
'/alerts?tab=AlertRules&relativeTime=30m',
'_blank',
);
});
it('calls window.open with the given path and _blank target', () => {
openInNewTab('/dashboard');
expect(window.open).toHaveBeenCalledWith('/dashboard', '_blank');
});
describe('at basePath="/signoz/"', () => {
let m: NavigationModule;
beforeEach(() => {
jest.spyOn(window, 'open').mockImplementation();
m = loadNavigationModule('/signoz/');
});
it('handles full URLs', () => {
openInNewTab('https://example.com/page');
expect(window.open).toHaveBeenCalledWith(
'https://example.com/page',
'_blank',
);
});
it('prepends base path to internal paths', () => {
m.openInNewTab('/dashboard');
expect(window.open).toHaveBeenCalledWith('/signoz/dashboard', '_blank');
});
it('passes through external URLs unchanged', () => {
m.openInNewTab('https://example.com/page');
expect(window.open).toHaveBeenCalledWith(
'https://example.com/page',
'_blank',
);
});
it('is idempotent — does not double-prefix an already-prefixed path', () => {
m.openInNewTab('/signoz/dashboard');
expect(window.open).toHaveBeenCalledWith('/signoz/dashboard', '_blank');
});
it('handles paths with query strings', () => {
openInNewTab('/alerts?tab=AlertRules&relativeTime=30m');
expect(window.open).toHaveBeenCalledWith(
'/alerts?tab=AlertRules&relativeTime=30m',
'_blank',
);
});
});
});

View File

@@ -1,50 +0,0 @@
// Read once at module init — avoids a DOM query on every axios request.
const _basePath: string = ((): string => {
const href = document.querySelector('base')?.getAttribute('href') ?? '/';
return href.endsWith('/') ? href : `${href}/`;
})();
/** Returns the runtime base path — always trailing-slashed. e.g. "/" or "/signoz/" */
export function getBasePath(): string {
return _basePath;
}
/**
* Prepends the base path to an internal absolute path.
* Idempotent and safe to call on any value.
*
* withBasePath('/logs') → '/signoz/logs'
* withBasePath('/signoz/logs') → '/signoz/logs' (already prefixed)
* withBasePath('https://x.com') → 'https://x.com' (external, passthrough)
*/
export function withBasePath(path: string): string {
if (!path.startsWith('/')) {
return path;
}
if (_basePath === '/') {
return path;
}
if (path.startsWith(_basePath) || path === _basePath.slice(0, -1)) {
return path;
}
return _basePath + path.slice(1);
}
/**
* Full absolute URL — for copy-to-clipboard and window.open calls.
* getAbsoluteUrl(ROUTES.LOGS_EXPLORER) → 'https://host/signoz/logs/logs-explorer'
*/
export function getAbsoluteUrl(path: string): string {
return window.location.origin + withBasePath(path);
}
/**
* Origin + base path without trailing slash — for sending to the backend
* as frontendBaseUrl in invite / password-reset email flows.
* getBaseUrl() → 'https://host/signoz'
*/
export function getBaseUrl(): string {
return (
window.location.origin + (_basePath === '/' ? '' : _basePath.slice(0, -1))
);
}

View File

@@ -1,5 +1,6 @@
import { withBasePath } from 'utils/basePath';
/**
* Opens the given path in a new browser tab.
*/
export const openInNewTab = (path: string): void => {
window.open(withBasePath(path), '_blank');
window.open(path, '_blank');
};

View File

@@ -34,7 +34,7 @@ const ruleName = 'local/no-unsupported-asset-url';
* e.g. "url('/a.svg') url('/b.png') repeat" → ['/a.svg', '/b.png']
*/
function extractUrlPaths(value) {
if (typeof value !== 'string') {return [];}
if (typeof value !== 'string') return [];
const paths = [];
const urlPattern = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)/g;
let match = urlPattern.exec(value);
@@ -71,7 +71,7 @@ const messages = stylelint.utils.ruleMessages(ruleName, {
*/
const rule = (primaryOption) => {
return (root, result) => {
if (!primaryOption) {return;}
if (!primaryOption) return;
root.walkDecls((decl) => {
const urlPaths = extractUrlPaths(decl.value);

View File

@@ -10,18 +10,6 @@ import { createHtmlPlugin } from 'vite-plugin-html';
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
import tsconfigPaths from 'vite-tsconfig-paths';
// In dev the Go backend is not involved, so replace the [[.BaseHref]] placeholder
// with "/" so relative assets resolve correctly from the Vite dev server.
function devBasePathPlugin(): Plugin {
return {
name: 'dev-base-path',
apply: 'serve',
transformIndexHtml(html): string {
return html.replaceAll('[[.BaseHref]]', '/');
},
};
}
function rawMarkdownPlugin(): Plugin {
return {
name: 'raw-markdown',
@@ -44,7 +32,6 @@ export default defineConfig(
const plugins = [
tsconfigPaths(),
rawMarkdownPlugin(),
devBasePathPlugin(),
react(),
createHtmlPlugin({
inject: {
@@ -137,7 +124,6 @@ export default defineConfig(
'process.env.TUNNEL_DOMAIN': JSON.stringify(env.VITE_TUNNEL_DOMAIN),
'process.env.DOCS_BASE_URL': JSON.stringify(env.VITE_DOCS_BASE_URL),
},
base: './',
build: {
sourcemap: true,
outDir: 'build',

1
go.mod
View File

@@ -66,7 +66,6 @@ require (
github.com/uptrace/bun/dialect/pgdialect v1.2.9
github.com/uptrace/bun/dialect/sqlitedialect v1.2.9
github.com/uptrace/bun/extra/bunotel v1.2.9
github.com/yuin/goldmark v1.7.16
go.opentelemetry.io/collector/confmap v1.51.0
go.opentelemetry.io/collector/otelcol v0.144.0
go.opentelemetry.io/collector/pdata v1.51.0

2
go.sum
View File

@@ -1153,8 +1153,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A=

View File

@@ -10,8 +10,34 @@ import (
"github.com/gorilla/mux"
)
var (
serviceAccountAdminRoles = []string{
authtypes.SigNozAdminRoleName,
}
serviceAccountReadRoles = []string{
authtypes.SigNozAdminRoleName,
authtypes.SigNozEditorRoleName,
authtypes.SigNozViewerRoleName,
}
)
func serviceAccountCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]authtypes.Selector, error) {
return []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeMetaResources, authtypes.WildCardSelectorString),
}, nil
}
func serviceAccountInstanceSelectorCallback(req *http.Request, _ authtypes.Claims) ([]authtypes.Selector, error) {
id := mux.Vars(req)["id"]
return []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeMetaResource, id),
authtypes.MustNewSelector(authtypes.TypeMetaResource, authtypes.WildCardSelectorString),
}, nil
}
func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Create), handler.OpenAPIDef{
// POST /api/v1/service_accounts — Create (admin only)
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Create, authtypes.RelationCreate, serviceaccounttypes.TypeableMetaResourcesServiceAccounts, serviceAccountCollectionSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
ID: "CreateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Create service account",
@@ -28,7 +54,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.List), handler.OpenAPIDef{
// GET /api/v1/service_accounts — List (admin, editor, viewer)
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.Check(provider.serviceAccountHandler.List, authtypes.RelationList, serviceaccounttypes.TypeableMetaResourcesServiceAccounts, serviceAccountCollectionSelectorCallback, serviceAccountReadRoles), handler.OpenAPIDef{
ID: "ListServiceAccounts",
Tags: []string{"serviceaccount"},
Summary: "List service accounts",
@@ -40,11 +67,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
// GET /api/v1/service_accounts/me — GetMe (open access, self)
if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.authZ.OpenAccess(provider.serviceAccountHandler.GetMe), handler.OpenAPIDef{
ID: "GetMyServiceAccount",
Tags: []string{"serviceaccount"},
@@ -62,7 +90,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Get), handler.OpenAPIDef{
// GET /api/v1/service_accounts/{id} — Get (admin, editor, viewer)
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Get, authtypes.RelationRead, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountReadRoles), handler.OpenAPIDef{
ID: "GetServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Gets a service account",
@@ -74,12 +103,13 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.GetRoles), handler.OpenAPIDef{
// GET /api/v1/service_accounts/{id}/roles — GetRoles (admin, editor, viewer)
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.Check(provider.serviceAccountHandler.GetRoles, authtypes.RelationRead, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountReadRoles), handler.OpenAPIDef{
ID: "GetServiceAccountRoles",
Tags: []string{"serviceaccount"},
Summary: "Gets service account roles",
@@ -91,12 +121,13 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.SetRole), handler.OpenAPIDef{
// POST /api/v1/service_accounts/{id}/roles — SetRole (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.Check(provider.serviceAccountHandler.SetRole, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
ID: "CreateServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
@@ -113,7 +144,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.DeleteRole), handler.OpenAPIDef{
// DELETE /api/v1/service_accounts/{id}/roles/{rid} — DeleteRole (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.DeleteRole, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
ID: "DeleteServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
@@ -130,6 +162,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// PUT /api/v1/service_accounts/me — UpdateMe (open access, self)
if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.authZ.OpenAccess(provider.serviceAccountHandler.UpdateMe), handler.OpenAPIDef{
ID: "UpdateMyServiceAccount",
Tags: []string{"serviceaccount"},
@@ -147,7 +180,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Update), handler.OpenAPIDef{
// PUT /api/v1/service_accounts/{id} — Update (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Update, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
ID: "UpdateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account",
@@ -164,7 +198,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Delete), handler.OpenAPIDef{
// DELETE /api/v1/service_accounts/{id} — Delete (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Delete, authtypes.RelationDelete, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
ID: "DeleteServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Deletes a service account",
@@ -181,7 +216,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.CreateFactorAPIKey), handler.OpenAPIDef{
// POST /api/v1/service_accounts/{id}/keys — CreateFactorAPIKey (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.Check(provider.serviceAccountHandler.CreateFactorAPIKey, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
ID: "CreateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Create a service account key",
@@ -198,7 +234,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.ListFactorAPIKey), handler.OpenAPIDef{
// GET /api/v1/service_accounts/{id}/keys — ListFactorAPIKey (admin, editor, viewer)
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.Check(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.RelationRead, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountReadRoles), handler.OpenAPIDef{
ID: "ListServiceAccountKeys",
Tags: []string{"serviceaccount"},
Summary: "List service account keys",
@@ -210,12 +247,13 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.UpdateFactorAPIKey), handler.OpenAPIDef{
// PUT /api/v1/service_accounts/{id}/keys/{fid} — UpdateFactorAPIKey (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
ID: "UpdateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account key",
@@ -232,7 +270,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.RevokeFactorAPIKey), handler.OpenAPIDef{
// DELETE /api/v1/service_accounts/{id}/keys/{fid} — RevokeFactorAPIKey (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.RevokeFactorAPIKey, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
ID: "RevokeServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Revoke a service account key",

View File

@@ -376,6 +376,21 @@ func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID
}
func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, role *authtypes.Role) error {
// Verify the caller holds the role they are trying to assign. This prevents
// privilege escalation — a caller cannot grant a role they don't have.
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return err
}
roleSelector := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, role.Name),
}
err = module.authz.CheckWithTupleCreation(ctx, claims, orgID, authtypes.RelationAssignee, authtypes.TypeableRole, roleSelector, roleSelector)
if err != nil {
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "caller does not hold the role %q being assigned", role.Name)
}
serviceAccount, err := module.GetWithRoles(ctx, orgID, id)
if err != nil {
return err
@@ -430,3 +445,4 @@ func apiKeyCacheKey(apiKey string) string {
func identityCacheKey(serviceAccountID valuer.UUID) string {
return "identity::" + serviceAccountID.String()
}

View File

@@ -0,0 +1,135 @@
package implserviceaccount
import (
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// typeableRegistry is a standalone RegisterTypeable implementation that can be
// used independently of the module lifecycle. This is needed because authz
// initialization requires RegisterTypeable entries before the service account
// module is created (the module depends on authz).
type typeableRegistry struct{}
func NewTypeableRegistry() authz.RegisterTypeable {
return &typeableRegistry{}
}
func (registry *typeableRegistry) MustGetTypeables() []authtypes.Typeable {
return []authtypes.Typeable{
serviceaccounttypes.TypeableMetaResourceServiceAccount,
serviceaccounttypes.TypeableMetaResourcesServiceAccounts,
}
}
func (registry *typeableRegistry) MustGetManagedRoleTransactions() map[string][]*authtypes.Transaction {
return map[string][]*authtypes.Transaction{
authtypes.SigNozAdminRoleName: {
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationCreate,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResources,
Name: serviceaccounttypes.TypeableMetaResourcesServiceAccounts.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResources, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationList,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResources,
Name: serviceaccounttypes.TypeableMetaResourcesServiceAccounts.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResources, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationRead,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationUpdate,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationDelete,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
},
authtypes.SigNozEditorRoleName: {
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationList,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResources,
Name: serviceaccounttypes.TypeableMetaResourcesServiceAccounts.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResources, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationRead,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
},
authtypes.SigNozViewerRoleName: {
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationList,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResources,
Name: serviceaccounttypes.TypeableMetaResourcesServiceAccounts.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResources, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationRead,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
},
}
}

View File

@@ -100,7 +100,7 @@ func New(
sqlstoreProviderFactories factory.NamedMap[factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config]],
telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]],
authNsCallback func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error),
authzCallback func(context.Context, sqlstore.SQLStore, licensing.Licensing, dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
authzCallback func(context.Context, sqlstore.SQLStore, licensing.Licensing, ...authz.RegisterTypeable) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing) dashboard.Module,
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
@@ -328,8 +328,11 @@ func New(
// Initialize dashboard module (needed for authz registry)
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing)
// Initialize service account typeable registry (decoupled from module lifecycle for DI)
serviceAccountRegistry := implserviceaccount.NewTypeableRegistry()
// Initialize authz
authzProviderFactory, err := authzCallback(ctx, sqlstore, licensing, dashboard)
authzProviderFactory, err := authzCallback(ctx, sqlstore, licensing, dashboard, serviceAccountRegistry)
if err != nil {
return nil, err
}

View File

@@ -1,755 +0,0 @@
package blockkit
import (
"bytes"
"encoding/json"
"strings"
"github.com/SigNoz/signoz/pkg/types/templatingtypes"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
extensionast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/util"
)
// Extender is a goldmark.Extender that registers the Block Kit node renderer
// together with the GFM extensions it relies on (tables, strikethrough, task
// lists).
var Extender goldmark.Extender = &extender{}
type extender struct{}
func (e *extender) Extend(m goldmark.Markdown) {
extension.Table.Extend(m)
extension.Strikethrough.Extend(m)
extension.TaskList.Extend(m)
m.Renderer().AddOptions(
renderer.WithNodeRenderers(util.Prioritized(newRenderer(), 1)),
)
}
// listFrame tracks state for a single level of list nesting.
type listFrame struct {
style string // "bullet" or "ordered"
indent int
itemCount int
}
// listContext holds all state while processing a list tree.
type listContext struct {
result []templatingtypes.RichTextList
stack []listFrame
current *templatingtypes.RichTextList
currentItemInlines []interface{}
}
// tableContext holds state while processing a table.
type tableContext struct {
rows [][]templatingtypes.TableCell
currentRow []templatingtypes.TableCell
currentCellInlines []interface{}
isHeader bool
}
// nodeRenderer converts Markdown AST to Slack Block Kit JSON.
type nodeRenderer struct {
blocks []interface{}
mrkdwn strings.Builder
// holds active styles for the current rich text element
styleStack []templatingtypes.RichTextStyle
// holds the current list context while processing a list tree.
listCtx *listContext
// holds the current table context while processing a table.
tableCtx *tableContext
// stores the current blockquote depth while processing a blockquote.
// so blockquote with nested list can be rendered correctly.
blockquoteDepth int
}
func newRenderer() renderer.NodeRenderer {
return &nodeRenderer{}
}
// RegisterFuncs registers node rendering functions.
func (r *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
// Blocks
reg.Register(ast.KindDocument, r.renderDocument)
reg.Register(ast.KindHeading, r.renderHeading)
reg.Register(ast.KindParagraph, r.renderParagraph)
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
reg.Register(ast.KindBlockquote, r.renderBlockquote)
reg.Register(ast.KindList, r.renderList)
reg.Register(ast.KindListItem, r.renderListItem)
reg.Register(ast.KindImage, r.renderImage)
// Inlines
reg.Register(ast.KindText, r.renderText)
reg.Register(ast.KindEmphasis, r.renderEmphasis)
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
reg.Register(ast.KindLink, r.renderLink)
// Extensions
reg.Register(extensionast.KindStrikethrough, r.renderStrikethrough)
reg.Register(extensionast.KindTable, r.renderTable)
reg.Register(extensionast.KindTableHeader, r.renderTableHeader)
reg.Register(extensionast.KindTableRow, r.renderTableRow)
reg.Register(extensionast.KindTableCell, r.renderTableCell)
reg.Register(extensionast.KindTaskCheckBox, r.renderTaskCheckBox)
}
// inRichTextMode returns true when we're inside a list or table context
// in slack blockkit list and table items are rendered as rich_text elements
// if more cases are found in future those needs to be added here.
func (r *nodeRenderer) inRichTextMode() bool {
return r.listCtx != nil || r.tableCtx != nil
}
// currentStyle merges the stored style stack into templatingtypes.RichTextStyle
// which can be applied on rich text elements.
func (r *nodeRenderer) currentStyle() *templatingtypes.RichTextStyle {
s := templatingtypes.RichTextStyle{}
for _, f := range r.styleStack {
s.Bold = s.Bold || f.Bold
s.Italic = s.Italic || f.Italic
s.Strike = s.Strike || f.Strike
s.Code = s.Code || f.Code
}
if s == (templatingtypes.RichTextStyle{}) {
return nil
}
return &s
}
// flushMrkdwn collects markdown text and adds it as a templatingtypes.SectionBlock with mrkdwn text
// whenever starting a new block we flush markdown to render it as a separate block.
func (r *nodeRenderer) flushMrkdwn() {
text := strings.TrimSpace(r.mrkdwn.String())
if text != "" {
r.blocks = append(r.blocks, templatingtypes.SectionBlock{
Type: "section",
Text: &templatingtypes.TextObject{
Type: "mrkdwn",
Text: text,
},
})
}
r.mrkdwn.Reset()
}
// addInline adds an inline element to the appropriate context.
func (r *nodeRenderer) addInline(el interface{}) {
if r.listCtx != nil {
r.listCtx.currentItemInlines = append(r.listCtx.currentItemInlines, el)
} else if r.tableCtx != nil {
r.tableCtx.currentCellInlines = append(r.tableCtx.currentCellInlines, el)
}
}
// --- Document ---
func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.blocks = nil
r.mrkdwn.Reset()
r.styleStack = nil
r.listCtx = nil
r.tableCtx = nil
r.blockquoteDepth = 0
} else {
// on exiting the document node write the json for the collected blocks.
r.flushMrkdwn()
var data []byte
var err error
if len(r.blocks) > 0 {
data, err = json.Marshal(r.blocks)
if err != nil {
return ast.WalkStop, err
}
} else {
// if no blocks are collected, write an empty array.
data = []byte("[]")
}
_, err = w.Write(data)
if err != nil {
return ast.WalkStop, err
}
}
return ast.WalkContinue, nil
}
// --- Heading ---
func (r *nodeRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.mrkdwn.WriteString("*")
} else {
r.mrkdwn.WriteString("*\n")
}
return ast.WalkContinue, nil
}
// --- Paragraph ---
func (r *nodeRenderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if r.mrkdwn.Len() > 0 {
text := r.mrkdwn.String()
if !strings.HasSuffix(text, "\n") {
r.mrkdwn.WriteString("\n")
}
}
// handling of nested blockquotes
if r.blockquoteDepth > 0 {
r.mrkdwn.WriteString(strings.Repeat("> ", r.blockquoteDepth))
}
}
return ast.WalkContinue, nil
}
// --- ThematicBreak ---
func (r *nodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.flushMrkdwn()
r.blocks = append(r.blocks, templatingtypes.DividerBlock{Type: "divider"})
}
return ast.WalkContinue, nil
}
// --- CodeBlock (indented) ---
func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
r.flushMrkdwn()
var buf bytes.Buffer
lines := node.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
buf.Write(line.Value(source))
}
text := buf.String()
// Remove trailing newline
text = strings.TrimRight(text, "\n")
// Slack API rejects empty text in rich_text_preformatted elements
if text == "" {
text = " "
}
elements := []interface{}{
templatingtypes.RichTextInline{Type: "text", Text: text},
}
r.blocks = append(r.blocks, templatingtypes.RichTextBlock{
Type: "rich_text",
Elements: []interface{}{
templatingtypes.RichTextPreformatted{
Type: "rich_text_preformatted",
Elements: elements,
Border: 0,
},
},
})
return ast.WalkContinue, nil
}
// --- FencedCodeBlock ---
func (r *nodeRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
r.flushMrkdwn()
n := node.(*ast.FencedCodeBlock)
var buf bytes.Buffer
lines := node.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
buf.Write(line.Value(source))
}
text := buf.String()
text = strings.TrimRight(text, "\n")
// Slack API rejects empty text in rich_text_preformatted elements
if text == "" {
text = " "
}
elements := []interface{}{
templatingtypes.RichTextInline{Type: "text", Text: text},
}
// If language is specified, collect it.
var language string
lang := n.Language(source)
if len(lang) > 0 {
language = string(lang)
}
// Add the preformatted block to the blocks slice with the collected language.
r.blocks = append(r.blocks, templatingtypes.RichTextBlock{
Type: "rich_text",
Elements: []interface{}{
templatingtypes.RichTextPreformatted{
Type: "rich_text_preformatted",
Elements: elements,
Border: 0,
Language: language,
},
},
})
return ast.WalkSkipChildren, nil
}
// --- Blockquote ---
func (r *nodeRenderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.blockquoteDepth++
} else {
r.blockquoteDepth--
}
return ast.WalkContinue, nil
}
// --- List ---
func (r *nodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
list := node.(*ast.List)
if entering {
style := "bullet"
if list.IsOrdered() {
style = "ordered"
}
if r.listCtx == nil {
// Top-level list: flush mrkdwn and create context
r.flushMrkdwn()
r.listCtx = &listContext{}
} else {
// Nested list: check if we already have some collected list items that needs to be flushed.
// in slack blockkit, list items with different levels of indentation are added as different rich_text_list blocks.
if len(r.listCtx.currentItemInlines) > 0 {
sec := templatingtypes.RichTextBlock{
Type: "rich_text_section",
Elements: r.listCtx.currentItemInlines,
}
if r.listCtx.current != nil {
r.listCtx.current.Elements = append(r.listCtx.current.Elements, sec)
}
r.listCtx.currentItemInlines = nil
// Increment parent's itemCount
if len(r.listCtx.stack) > 0 {
r.listCtx.stack[len(r.listCtx.stack)-1].itemCount++
}
}
// Finalize current list to result only if items were collected
if r.listCtx.current != nil && len(r.listCtx.current.Elements) > 0 {
r.listCtx.result = append(r.listCtx.result, *r.listCtx.current)
}
}
// the stack accumulated till this level derives hte indentation
// the stack get's collected as we go in more nested levels of list
// and as we get our of the nesting we remove the items from the slack
indent := len(r.listCtx.stack)
r.listCtx.stack = append(r.listCtx.stack, listFrame{
style: style,
indent: indent,
itemCount: 0,
})
newList := &templatingtypes.RichTextList{
Type: "rich_text_list",
Style: style,
Indent: indent,
Border: 0,
Elements: []interface{}{},
}
// Handle ordered list with start > 1
if list.IsOrdered() && list.Start > 1 {
newList.Offset = list.Start - 1
}
r.listCtx.current = newList
} else {
// Leaving list: finalize current list
if r.listCtx.current != nil && len(r.listCtx.current.Elements) > 0 {
r.listCtx.result = append(r.listCtx.result, *r.listCtx.current)
}
// Pop stack to so upcoming indentations can be handled correctly.
r.listCtx.stack = r.listCtx.stack[:len(r.listCtx.stack)-1]
if len(r.listCtx.stack) > 0 {
// Resume parent: start a new list segment at parent indent/style
parent := &r.listCtx.stack[len(r.listCtx.stack)-1]
newList := &templatingtypes.RichTextList{
Type: "rich_text_list",
Style: parent.style,
Indent: parent.indent,
Border: 0,
Elements: []interface{}{},
}
// Set offset for ordered parent continuation
if parent.style == "ordered" && parent.itemCount > 0 {
newList.Offset = parent.itemCount
}
r.listCtx.current = newList
} else {
// Top-level list is done since all stack are popped: build templatingtypes.RichTextBlock if non-empty
if len(r.listCtx.result) > 0 {
elements := make([]interface{}, len(r.listCtx.result))
for i, l := range r.listCtx.result {
elements[i] = l
}
r.blocks = append(r.blocks, templatingtypes.RichTextBlock{
Type: "rich_text",
Elements: elements,
})
}
r.listCtx = nil
}
}
return ast.WalkContinue, nil
}
// --- ListItem ---
func (r *nodeRenderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.listCtx.currentItemInlines = nil
} else {
// Only add if there are inlines (might be empty after nested list consumed them)
if len(r.listCtx.currentItemInlines) > 0 {
sec := templatingtypes.RichTextBlock{
Type: "rich_text_section",
Elements: r.listCtx.currentItemInlines,
}
if r.listCtx.current != nil {
r.listCtx.current.Elements = append(r.listCtx.current.Elements, sec)
}
r.listCtx.currentItemInlines = nil
// Increment parent frame's itemCount
if len(r.listCtx.stack) > 0 {
r.listCtx.stack[len(r.listCtx.stack)-1].itemCount++
}
}
}
return ast.WalkContinue, nil
}
// --- Table ---
// when table is encountered, we flush the markdown and create a table context.
// when header row is encountered, we set the isHeader flag to true
// when each row ends in renderTableRow we add that row to rows array of table context.
// when table cell is encountered, we apply header related styles to the collected inline items,
// all inline items are parsed as separate AST items like list item, links, text, etc. are collected
// using the addInline function and wrapped in a rich_text_section block.
func (r *nodeRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.flushMrkdwn()
r.tableCtx = &tableContext{}
} else {
// Pad short rows to match header column count for valid Block Kit payload
// without this slack blockkit attachment is invalid and the API fails
rows := r.tableCtx.rows
if len(rows) > 0 {
maxCols := len(rows[0])
for i, row := range rows {
for len(row) < maxCols {
emptySec := templatingtypes.RichTextBlock{
Type: "rich_text_section",
Elements: []interface{}{templatingtypes.RichTextInline{Type: "text", Text: " "}},
}
row = append(row, templatingtypes.TableCell{
Type: "rich_text",
Elements: []interface{}{emptySec},
})
}
rows[i] = row
}
}
r.blocks = append(r.blocks, templatingtypes.TableBlock{
Type: "table",
Rows: rows,
})
r.tableCtx = nil
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderTableHeader(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.tableCtx.isHeader = true
r.tableCtx.currentRow = nil
} else {
r.tableCtx.rows = append(r.tableCtx.rows, r.tableCtx.currentRow)
r.tableCtx.currentRow = nil
r.tableCtx.isHeader = false
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderTableRow(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.tableCtx.currentRow = nil
} else {
r.tableCtx.rows = append(r.tableCtx.rows, r.tableCtx.currentRow)
r.tableCtx.currentRow = nil
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderTableCell(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.tableCtx.currentCellInlines = nil
} else {
// If header, make text bold for the collected inline items.
if r.tableCtx.isHeader {
for i, el := range r.tableCtx.currentCellInlines {
if inline, ok := el.(templatingtypes.RichTextInline); ok {
if inline.Style == nil {
inline.Style = &templatingtypes.RichTextStyle{Bold: true}
} else {
inline.Style.Bold = true
}
r.tableCtx.currentCellInlines[i] = inline
}
}
}
// Ensure cell has at least one element for valid Block Kit payload
if len(r.tableCtx.currentCellInlines) == 0 {
r.tableCtx.currentCellInlines = []interface{}{
templatingtypes.RichTextInline{Type: "text", Text: " "},
}
}
// All inline items that are collected for a table cell are wrapped in a rich_text_section block.
sec := templatingtypes.RichTextBlock{
Type: "rich_text_section",
Elements: r.tableCtx.currentCellInlines,
}
// The rich_text_section block is wrapped in a rich_text block.
cell := templatingtypes.TableCell{
Type: "rich_text",
Elements: []interface{}{sec},
}
r.tableCtx.currentRow = append(r.tableCtx.currentRow, cell)
r.tableCtx.currentCellInlines = nil
}
return ast.WalkContinue, nil
}
// --- TaskCheckBox ---
func (r *nodeRenderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*extensionast.TaskCheckBox)
text := "[ ] "
if n.IsChecked {
text = "[x] "
}
if r.inRichTextMode() {
r.addInline(templatingtypes.RichTextInline{Type: "text", Text: text})
} else {
r.mrkdwn.WriteString(text)
}
return ast.WalkContinue, nil
}
// --- Inline: Text ---
func (r *nodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.Text)
value := string(n.Segment.Value(source))
if r.inRichTextMode() {
r.addInline(templatingtypes.RichTextInline{
Type: "text",
Text: value,
Style: r.currentStyle(),
})
if n.HardLineBreak() || n.SoftLineBreak() {
r.addInline(templatingtypes.RichTextInline{Type: "text", Text: "\n"})
}
} else {
r.mrkdwn.WriteString(value)
if n.HardLineBreak() || n.SoftLineBreak() {
r.mrkdwn.WriteString("\n")
if r.blockquoteDepth > 0 {
r.mrkdwn.WriteString(strings.Repeat("> ", r.blockquoteDepth))
}
}
}
return ast.WalkContinue, nil
}
// --- Inline: Emphasis ---
func (r *nodeRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Emphasis)
if r.inRichTextMode() {
if entering {
s := templatingtypes.RichTextStyle{}
if n.Level == 1 {
s.Italic = true
} else {
s.Bold = true
}
r.styleStack = append(r.styleStack, s)
} else {
// the collected style gets used by the rich text element using currentStyle()
// so we remove this style from the stack.
if len(r.styleStack) > 0 {
r.styleStack = r.styleStack[:len(r.styleStack)-1]
}
}
} else {
if n.Level == 1 {
r.mrkdwn.WriteString("_")
} else {
r.mrkdwn.WriteString("*")
}
}
return ast.WalkContinue, nil
}
// --- Inline: Strikethrough ---
func (r *nodeRenderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if r.inRichTextMode() {
if entering {
r.styleStack = append(r.styleStack, templatingtypes.RichTextStyle{Strike: true})
} else {
// the collected style gets used by the rich text element using currentStyle()
// so we remove this style from the stack.
if len(r.styleStack) > 0 {
r.styleStack = r.styleStack[:len(r.styleStack)-1]
}
}
} else {
r.mrkdwn.WriteString("~")
}
return ast.WalkContinue, nil
}
// --- Inline: CodeSpan ---
func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if r.inRichTextMode() {
// Collect all child text
var buf bytes.Buffer
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
if t, ok := c.(*ast.Text); ok {
v := t.Segment.Value(source)
if bytes.HasSuffix(v, []byte("\n")) {
buf.Write(v[:len(v)-1])
buf.WriteByte(' ')
} else {
buf.Write(v)
}
} else if s, ok := c.(*ast.String); ok {
buf.Write(s.Value)
}
}
style := r.currentStyle()
if style == nil {
style = &templatingtypes.RichTextStyle{Code: true}
} else {
style.Code = true
}
r.addInline(templatingtypes.RichTextInline{
Type: "text",
Text: buf.String(),
Style: style,
})
return ast.WalkSkipChildren, nil
}
// mrkdwn mode
r.mrkdwn.WriteByte('`')
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
if t, ok := c.(*ast.Text); ok {
v := t.Segment.Value(source)
if bytes.HasSuffix(v, []byte("\n")) {
r.mrkdwn.Write(v[:len(v)-1])
r.mrkdwn.WriteByte(' ')
} else {
r.mrkdwn.Write(v)
}
} else if s, ok := c.(*ast.String); ok {
r.mrkdwn.Write(s.Value)
}
}
r.mrkdwn.WriteByte('`')
return ast.WalkSkipChildren, nil
}
// --- Inline: Link ---
func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Link)
if r.inRichTextMode() {
if entering {
// Walk the entire subtree to collect text from all descendants,
// including nested inline nodes like emphasis, strong, code spans, etc.
var buf bytes.Buffer
_ = ast.Walk(node, func(child ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering || child == node {
return ast.WalkContinue, nil
}
if t, ok := child.(*ast.Text); ok {
buf.Write(t.Segment.Value(source))
} else if s, ok := child.(*ast.String); ok {
buf.Write(s.Value)
}
return ast.WalkContinue, nil
})
// Once we've collected the text for the link (given it was present)
// let's add the link to the rich text block.
r.addInline(templatingtypes.RichTextLink{
Type: "link",
URL: string(n.Destination),
Text: buf.String(),
Style: r.currentStyle(),
})
return ast.WalkSkipChildren, nil
}
} else {
if entering {
r.mrkdwn.WriteString("<")
r.mrkdwn.Write(n.Destination)
r.mrkdwn.WriteString("|")
} else {
r.mrkdwn.WriteString(">")
}
}
return ast.WalkContinue, nil
}
// --- Image (skip) ---
func (r *nodeRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
return ast.WalkSkipChildren, nil
}

View File

@@ -1,542 +0,0 @@
package blockkit
import (
"bytes"
"encoding/json"
"testing"
"github.com/yuin/goldmark"
)
func jsonEqual(a, b string) bool {
var va, vb interface{}
if err := json.Unmarshal([]byte(a), &va); err != nil {
return false
}
if err := json.Unmarshal([]byte(b), &vb); err != nil {
return false
}
ja, _ := json.Marshal(va)
jb, _ := json.Marshal(vb)
return string(ja) == string(jb)
}
func prettyJSON(s string) string {
var v interface{}
if err := json.Unmarshal([]byte(s), &v); err != nil {
return s
}
b, _ := json.MarshalIndent(v, "", " ")
return string(b)
}
func TestRenderer(t *testing.T) {
tests := []struct {
name string
markdown string
expected string
}{
{
name: "empty input",
markdown: "",
expected: `[]`,
},
{
name: "simple paragraph",
markdown: "Hello world",
expected: `[
{
"type": "section",
"text": { "type": "mrkdwn", "text": "Hello world" }
}
]`,
},
{
name: "heading",
markdown: "# My Heading",
expected: `[
{
"type": "section",
"text": { "type": "mrkdwn", "text": "*My Heading*" }
}
]`,
},
{
name: "multiple paragraphs",
markdown: "First paragraph\n\nSecond paragraph",
expected: `[
{
"type": "section",
"text": { "type": "mrkdwn", "text": "First paragraph\nSecond paragraph" }
}
]`,
},
{
name: "todo list ",
markdown: "- [ ] item 1\n- [x] item 2",
expected: `[
{
"type": "rich_text",
"elements": [
{
"border": 0,
"elements": [
{ "elements": [ { "text": "[ ] ", "type": "text" }, { "text": "item 1", "type": "text" } ], "type": "rich_text_section" },
{ "elements": [ { "text": "[x] ", "type": "text" }, { "text": "item 2", "type": "text" } ], "type": "rich_text_section" }
],
"indent": 0,
"style": "bullet",
"type": "rich_text_list"
}
]
}
]`,
},
{
name: "thematic break between paragraphs",
markdown: "Before\n\n---\n\nAfter",
expected: `[
{ "type": "section", "text": { "type": "mrkdwn", "text": "Before" } },
{ "type": "divider" },
{ "type": "section", "text": { "type": "mrkdwn", "text": "After" } }
]`,
},
{
name: "fenced code block with language",
markdown: "```go\nfmt.Println(\"hello\")\n```",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_preformatted",
"border": 0,
"language": "go",
"elements": [
{ "type": "text", "text": "fmt.Println(\"hello\")" }
]
}
]
}
]`,
},
{
name: "indented code block",
markdown: " code line 1\n code line 2",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_preformatted",
"border": 0,
"elements": [
{ "type": "text", "text": "code line 1\ncode line 2" }
]
}
]
}
]`,
},
{
name: "empty fenced code block",
markdown: "```\n```",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_preformatted",
"border": 0,
"elements": [
{ "type": "text", "text": " " }
]
}
]
}
]`,
},
{
name: "simple bullet list",
markdown: "- item 1\n- item 2\n- item 3",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 1" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 2" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 3" }] }
]
}
]
}
]`,
},
{
name: "simple ordered list",
markdown: "1. first\n2. second\n3. third",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "first" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "second" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "third" }] }
]
}
]
}
]`,
},
{
name: "nested bullet list (2 levels)",
markdown: "- item 1\n- item 2\n - sub a\n - sub b\n- item 3",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 1" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 2" }] }
]
},
{
"type": "rich_text_list", "style": "bullet", "indent": 1, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sub a" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sub b" }] }
]
},
{
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 3" }] }
]
}
]
}
]`,
},
{
name: "nested ordered list with offset",
markdown: "1. first\n 1. nested-a\n 2. nested-b\n2. second\n3. third",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "first" }] }
]
},
{
"type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "nested-a" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "nested-b" }] }
]
},
{
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "second" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "third" }] }
]
}
]
}
]`,
},
{
name: "mixed ordered/bullet nesting",
markdown: "1. ordered\n - bullet child\n2. ordered again",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "ordered" }] }
]
},
{
"type": "rich_text_list", "style": "bullet", "indent": 1, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "bullet child" }] }
]
},
{
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "ordered again" }] }
]
}
]
}
]`,
},
{
name: "list items with bold/italic/link/code",
markdown: "- **bold item**\n- _italic item_\n- [link](http://example.com)\n- `code item`",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "bold item", "style": { "bold": true } }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "italic item", "style": { "italic": true } }] },
{ "type": "rich_text_section", "elements": [{ "type": "link", "url": "http://example.com", "text": "link" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "code item", "style": { "code": true } }] }
]
}
]
}
]`,
},
{
name: "table with header and body",
markdown: "| Name | Age |\n|------|-----|\n| Alice | 30 |",
expected: `[
{
"type": "table",
"rows": [
[
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Name", "style": { "bold": true } }] }
]},
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Age", "style": { "bold": true } }] }
]}
],
[
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Alice" }] }
]},
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "30" }] }
]}
]
]
}
]`,
},
{
name: "blockquote",
markdown: "> quoted text",
expected: `[
{
"type": "section",
"text": { "type": "mrkdwn", "text": "> quoted text" }
}
]`,
},
{
name: "blockquote with nested list",
markdown: "> item 1\n> > item 2\n> > item 3",
expected: `[
{
"text": {
"text": "> item 1\n> > item 2\n> > item 3",
"type": "mrkdwn"
},
"type": "section"
}
]`,
},
{
name: "inline formatting in paragraph",
markdown: "This is **bold** and _italic_ and ~strike~ and `code`",
expected: `[
{
"type": "section",
"text": { "type": "mrkdwn", "text": "This is *bold* and _italic_ and ~strike~ and ` + "`code`" + `" }
}
]`,
},
{
name: "link in paragraph",
markdown: "Visit [Google](http://google.com)",
expected: `[
{
"type": "section",
"text": { "type": "mrkdwn", "text": "Visit <http://google.com|Google>" }
}
]`,
},
{
name: "image is skipped",
markdown: "![alt](http://example.com/image.png)",
// For image skip the block and return empty array
expected: `[]`,
},
{
name: "paragraph then list then paragraph",
markdown: "Before\n\n- item\n\nAfter",
expected: `[
{ "type": "section", "text": { "type": "mrkdwn", "text": "Before" } },
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item" }] }
]
}
]
},
{ "type": "section", "text": { "type": "mrkdwn", "text": "After" } }
]`,
},
{
name: "ordered list with start > 1",
markdown: "5. fifth\n6. sixth",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 4,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "fifth" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sixth" }] }
]
}
]
}
]`,
},
{
name: "deeply nested ordered list (3 levels) with offsets",
markdown: "1. Some things\n\t1. are best left\n2. to the fate\n\t1. of the world\n\t\t1. and then\n\t\t2. this is how\n3. it turns out to be",
expected: `[
{
"type": "rich_text",
"elements": [
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Some things" }] }] },
{ "type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "are best left" }] }] },
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "to the fate" }] }] },
{ "type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "of the world" }] }] },
{ "type": "rich_text_list", "style": "ordered", "indent": 2, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "and then" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "this is how" }] }
]
},
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 2,
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "it turns out to be" }] }] }
]
}
]`,
},
{
name: "link with bold label in list item",
markdown: "- [**docs**](http://example.com)",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "link", "url": "http://example.com", "text": "docs" }] }
]
}
]
}
]`,
},
{
name: "table with empty cell",
markdown: "| A | B |\n|---|---|\n| 1 | |",
expected: `[
{
"type": "table",
"rows": [
[
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "A", "style": { "bold": true } }] }
]},
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "B", "style": { "bold": true } }] }
]}
],
[
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "1" }] }
]},
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": " " }] }
]}
]
]
}
]`,
},
{
name: "table with missing column in row",
markdown: "| A | B |\n|---|---|\n| 1 |",
expected: `[
{
"type": "table",
"rows": [
[
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "A", "style": { "bold": true } }] }
]},
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "B", "style": { "bold": true } }] }
]}
],
[
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "1" }] }
]},
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": " " }] }
]}
]
]
}
]`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
md := goldmark.New(
goldmark.WithExtensions(Extender),
)
var buf bytes.Buffer
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
t.Fatalf("convert error: %v", err)
}
got := buf.String()
if !jsonEqual(got, tt.expected) {
t.Errorf("JSON mismatch\n\nMarkdown:\n%s\n\nExpected:\n%s\n\nGot:\n%s",
tt.markdown, prettyJSON(tt.expected), prettyJSON(got))
}
})
}
}

View File

@@ -1,9 +0,0 @@
// Package blockkit provides a goldmark extension that renders markdown as
// Slack Block Kit JSON (an array of blocks suitable for the Slack API's
// `blocks` payload field).
//
// The current Slack notifier uses the sibling mrkdwn package instead, because
// Block Kit's hard limits (one table per message, 50 blocks per message,
// 12k-character total text) would silently truncate or reject notifications
// with rich bodies. This package is kept in tree for future use.
package blockkit

View File

@@ -1,47 +0,0 @@
package markdownrenderer
import (
"sync"
"testing"
)
// TestConcurrentRender exercises every render entry point from many
// goroutines. Run with `go test -race` to catch shared-state regressions
// in the HTML, Block Kit or mrkdwn paths.
func TestConcurrentRender(t *testing.T) {
const goroutines = 32
const iterations = 20
markdowns := []string{
"# Heading\n\nparagraph **bold** *em* `code`",
"- item 1\n- item 2\n - nested\n- item 3",
"| a | b |\n| - | - |\n| 1 | 2 |\n| 3 | 4 |",
"> quote\n>> nested quote",
"```go\npackage main\n```",
"[link](https://example.com) and ![img](https://example.com/i.png)",
}
renderers := map[string]func(string) (string, error){
"html": RenderHTML,
"blockkit": RenderSlackBlockKit,
"mrkdwn": RenderSlackMrkdwn,
}
var wg sync.WaitGroup
for name, render := range renderers {
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func(name string, render func(string) (string, error), g int) {
defer wg.Done()
for i := 0; i < iterations; i++ {
md := markdowns[(g+i)%len(markdowns)]
if _, err := render(md); err != nil {
t.Errorf("%s render failed: %v", name, err)
return
}
}
}(name, render, g)
}
}
wg.Wait()
}

View File

@@ -1,3 +0,0 @@
// Package markdownrenderer renders markdown to the formats that alert
// notifications are delivered in: HTML, Slack Block Kit JSON, and Slack mrkdwn.
package markdownrenderer

View File

@@ -1,166 +0,0 @@
package markdownrenderer
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var testMarkdown = `# 🔥 FIRING: High CPU Usage on api-gateway
https://signoz.example.com/alerts/123
https://runbooks.example.com/cpu-high
## Alert Details
**Status:** **FIRING** | *api-gateway* service is experiencing high CPU usage. ~~resolved~~ previously.
Alert triggered because ` + "`cpu_usage_percent`" + ` exceeded threshold ` + "`90`" + `.
[View Alert in SigNoz](https://signoz.example.com/alerts/123) | [View Logs](https://signoz.example.com/logs?service=api-gateway) | [View Traces](https://signoz.example.com/traces?service=api-gateway)
![critical](https://signoz.example.com/badges/critical.svg "Critical Alert")
## Alert Labels
| Label | Value |
| -------- | ----------- |
| service | api-gateway |
| instance | pod-5a8b3c |
| severity | critical |
| region | us-east-1 |
## Remediation Steps
1. Check current CPU usage on the pod
2. Review recent deployments for regressions
3. Scale horizontally if load-related
1. Increase replica count
2. Verify HPA configuration
## Affected Services
* api-gateway
* auth-service
* payment-service
* payment-processor
* payment-validator
## Incident Checklist
- [x] Alert acknowledged
- [x] On-call notified
- [ ] Root cause identified
- [ ] Fix deployed
## Alert Rule Description
> This alert fires when CPU usage exceeds 90% for more than 5 minutes on any pod in the api-gateway service.
>
>> For capacity planning guidelines, see the infrastructure runbook section on horizontal pod autoscaling.
## Triggered Query
` + "```promql\navg(rate(container_cpu_usage_seconds_total{service=\"api-gateway\"}[5m])) by (pod) > 0.9\n```" + `
## Inline Details
This alert was generated by SigNoz using ` + "`alertmanager`" + ` rules engine.
`
func TestRenderHTML_Composite(t *testing.T) {
html, err := RenderHTML(testMarkdown)
require.NoError(t, err)
expected := "<h1>🔥 FIRING: High CPU Usage on api-gateway</h1>\n" +
"<p><a href=\"https://signoz.example.com/alerts/123\">https://signoz.example.com/alerts/123</a>\n<a href=\"https://runbooks.example.com/cpu-high\">https://runbooks.example.com/cpu-high</a></p>\n" +
"<h2>Alert Details</h2>\n" +
"<p><strong>Status:</strong> <strong>FIRING</strong> | <em>api-gateway</em> service is experiencing high CPU usage. <del>resolved</del> previously.</p>\n" +
"<p>Alert triggered because <code>cpu_usage_percent</code> exceeded threshold <code>90</code>.</p>\n" +
"<p><a href=\"https://signoz.example.com/alerts/123\">View Alert in SigNoz</a> | <a href=\"https://signoz.example.com/logs?service=api-gateway\">View Logs</a> | <a href=\"https://signoz.example.com/traces?service=api-gateway\">View Traces</a></p>\n" +
"<p><img src=\"https://signoz.example.com/badges/critical.svg\" alt=\"critical\" title=\"Critical Alert\"></p>\n" +
"<h2>Alert Labels</h2>\n" +
"<table>\n<thead>\n<tr>\n<th>Label</th>\n<th>Value</th>\n</tr>\n</thead>\n" +
"<tbody>\n<tr>\n<td>service</td>\n<td>api-gateway</td>\n</tr>\n" +
"<tr>\n<td>instance</td>\n<td>pod-5a8b3c</td>\n</tr>\n" +
"<tr>\n<td>severity</td>\n<td>critical</td>\n</tr>\n" +
"<tr>\n<td>region</td>\n<td>us-east-1</td>\n</tr>\n</tbody>\n</table>\n" +
"<h2>Remediation Steps</h2>\n" +
"<ol>\n<li>Check current CPU usage on the pod</li>\n<li>Review recent deployments for regressions</li>\n<li>Scale horizontally if load-related\n" +
"<ol>\n<li>Increase replica count</li>\n<li>Verify HPA configuration</li>\n</ol>\n</li>\n</ol>\n" +
"<h2>Affected Services</h2>\n" +
"<ul>\n<li>api-gateway</li>\n<li>auth-service</li>\n<li>payment-service\n" +
"<ul>\n<li>payment-processor</li>\n<li>payment-validator</li>\n</ul>\n</li>\n</ul>\n" +
"<h2>Incident Checklist</h2>\n" +
"<ul>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> Alert acknowledged</li>\n" +
"<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> On-call notified</li>\n" +
"<li><input disabled=\"\" type=\"checkbox\"> Root cause identified</li>\n" +
"<li><input disabled=\"\" type=\"checkbox\"> Fix deployed</li>\n</ul>\n" +
"<h2>Alert Rule Description</h2>\n" +
"<blockquote>\n<p>This alert fires when CPU usage exceeds 90% for more than 5 minutes on any pod in the api-gateway service.</p>\n" +
"<blockquote>\n<p>For capacity planning guidelines, see the infrastructure runbook section on horizontal pod autoscaling.</p>\n</blockquote>\n</blockquote>\n" +
"<h2>Triggered Query</h2>\n" +
"<pre><code class=\"language-promql\">avg(rate(container_cpu_usage_seconds_total{service=&quot;api-gateway&quot;}[5m])) by (pod) &gt; 0.9\n</code></pre>\n" +
"<h2>Inline Details</h2>\n" +
"<p>This alert was generated by SigNoz using <code>alertmanager</code> rules engine.</p>\n"
assert.Equal(t, expected, html)
}
func TestRenderHTML_InlineFormatting(t *testing.T) {
input := `# 🔥 FIRING: High CPU on api-gateway
## Alert Status
**FIRING** alert for *api-gateway* service — ~~resolved~~ previously.
Metric ` + "`cpu_usage_percent`" + ` exceeded threshold. [View in SigNoz](https://signoz.example.com/alerts/123)
![critical](https://signoz.example.com/badges/critical.svg "Critical Alert")`
html, err := RenderHTML(input)
require.NoError(t, err)
expected := "<h1>🔥 FIRING: High CPU on api-gateway</h1>\n<h2>Alert Status</h2>\n" +
"<p><strong>FIRING</strong> alert for <em>api-gateway</em> service — <del>resolved</del> previously.</p>\n" +
"<p>Metric <code>cpu_usage_percent</code> exceeded threshold. <a href=\"https://signoz.example.com/alerts/123\">View in SigNoz</a></p>\n" +
"<p><img src=\"https://signoz.example.com/badges/critical.svg\" alt=\"critical\" title=\"Critical Alert\"></p>\n"
assert.Equal(t, expected, html)
}
func TestRenderHTML_BlockElements(t *testing.T) {
input := `1. Check CPU usage on the pod
2. Review recent deployments
3. Scale horizontally if needed
* api-gateway
* auth-service
* payment-service
- [x] Alert acknowledged
- [ ] Root cause identified
> This alert fires when CPU usage exceeds 90% for more than 5 minutes.
| Label | Value |
| -------- | ----------- |
| service | api-gateway |
| severity | <no value> |
` + "```promql\navg(rate(container_cpu_usage_seconds_total{service=\"api-gateway\"}[5m])) by (pod) > 0.9\n```"
html, err := RenderHTML(input)
require.NoError(t, err)
expected := "<ol>\n<li>Check CPU usage on the pod</li>\n<li>Review recent deployments</li>\n<li>Scale horizontally if needed</li>\n</ol>\n" +
"<ul>\n<li>api-gateway</li>\n<li>auth-service</li>\n<li>payment-service</li>\n</ul>\n" +
"<ul>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> Alert acknowledged</li>\n" +
"<li><input disabled=\"\" type=\"checkbox\"> Root cause identified</li>\n</ul>\n" +
"<blockquote>\n<p>This alert fires when CPU usage exceeds 90% for more than 5 minutes.</p>\n</blockquote>\n" +
"<table>\n<thead>\n<tr>\n<th>Label</th>\n<th>Value</th>\n</tr>\n</thead>\n" +
"<tbody>\n<tr>\n<td>service</td>\n<td>api-gateway</td>\n</tr>\n" +
"<tr>\n<td>severity</td>\n<td>&lt;no value&gt;</td>\n</tr>\n</tbody>\n</table>\n" +
"<pre><code class=\"language-promql\">avg(rate(container_cpu_usage_seconds_total{service=&quot;api-gateway&quot;}[5m])) by (pod) &gt; 0.9\n</code></pre>\n"
assert.Equal(t, expected, html)
}

View File

@@ -1,62 +0,0 @@
package markdownrenderer
import (
"bytes"
"sync"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/blockkit"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/mrkdwn"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
)
// htmlRenderer is built once and shared. Goldmark's built-in HTML path and
// the novalue extension carry no mutable state, so a single goldmark.Markdown
// is safe to Convert concurrently.
var htmlRenderer = goldmark.New(goldmark.WithExtensions(extension.GFM, escapeNoValue))
// The Slack renderers hold per-document state on the node renderer (list
// context, table context, style stack, blockquote/list prefixes). Two
// goroutines calling Convert on the same goldmark.Markdown would corrupt
// that state. A sync.Pool gives each concurrent caller its own instance
// while still amortising the cost of building the pipeline.
var (
blockkitPool = sync.Pool{
New: func() any {
return goldmark.New(goldmark.WithExtensions(blockkit.Extender))
},
}
mrkdwnPool = sync.Pool{
New: func() any {
return goldmark.New(goldmark.WithExtensions(mrkdwn.Extender))
},
}
)
// RenderHTML converts markdown to HTML.
func RenderHTML(markdown string) (string, error) {
return render(htmlRenderer, markdown, "HTML")
}
// RenderSlackBlockKit converts markdown to a Slack Block Kit JSON array.
func RenderSlackBlockKit(markdown string) (string, error) {
md := blockkitPool.Get().(goldmark.Markdown)
defer blockkitPool.Put(md)
return render(md, markdown, "Slack Block Kit")
}
// RenderSlackMrkdwn converts markdown to Slack's mrkdwn format.
func RenderSlackMrkdwn(markdown string) (string, error) {
md := mrkdwnPool.Get().(goldmark.Markdown)
defer mrkdwnPool.Put(md)
return render(md, markdown, "Slack mrkdwn")
}
func render(md goldmark.Markdown, markdown string, format string) (string, error) {
var buf bytes.Buffer
if err := md.Convert([]byte(markdown), &buf); err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to convert markdown to %s", format)
}
return buf.String(), nil
}

View File

@@ -1,4 +0,0 @@
// Package mrkdwn provides a goldmark extension that renders markdown as
// Slack's "mrkdwn" text format (the legacy text field used in attachments
// and message payloads).
package mrkdwn

View File

@@ -1,404 +0,0 @@
package mrkdwn
import (
"bytes"
"fmt"
"strings"
"unicode/utf8"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
extensionast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/util"
)
// Extender is a goldmark.Extender that registers the Slack mrkdwn node
// renderer together with the GFM extensions it relies on (tables,
// strikethrough).
var Extender goldmark.Extender = &extender{}
type extender struct{}
func (e *extender) Extend(m goldmark.Markdown) {
extension.Table.Extend(m)
extension.Strikethrough.Extend(m)
m.Renderer().AddOptions(
renderer.WithNodeRenderers(util.Prioritized(newRenderer(), 1)),
)
}
// nodeRenderer renders nodes as Slack mrkdwn.
type nodeRenderer struct {
prefixes []string
}
func newRenderer() renderer.NodeRenderer {
return &nodeRenderer{}
}
// RegisterFuncs implements NodeRenderer.RegisterFuncs.
func (r *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
// Blocks
reg.Register(ast.KindDocument, r.renderDocument)
reg.Register(ast.KindHeading, r.renderHeading)
reg.Register(ast.KindBlockquote, r.renderBlockquote)
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindList, r.renderList)
reg.Register(ast.KindListItem, r.renderListItem)
reg.Register(ast.KindParagraph, r.renderParagraph)
reg.Register(ast.KindTextBlock, r.renderTextBlock)
reg.Register(ast.KindRawHTML, r.renderRawHTML)
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
// Inlines
reg.Register(ast.KindAutoLink, r.renderAutoLink)
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
reg.Register(ast.KindEmphasis, r.renderEmphasis)
reg.Register(ast.KindImage, r.renderImage)
reg.Register(ast.KindLink, r.renderLink)
reg.Register(ast.KindText, r.renderText)
// Extensions
reg.Register(extensionast.KindStrikethrough, r.renderStrikethrough)
reg.Register(extensionast.KindTable, r.renderTable)
}
func (r *nodeRenderer) writePrefix(w util.BufWriter) {
for _, p := range r.prefixes {
_, _ = w.WriteString(p)
}
}
// writeLineSeparator writes a newline followed by the current prefix.
// Used for tight separations (e.g., between list items or text blocks).
func (r *nodeRenderer) writeLineSeparator(w util.BufWriter) {
_ = w.WriteByte('\n')
r.writePrefix(w)
}
// writeBlockSeparator writes a blank line separator between block-level elements,
// respecting any active prefixes for proper nesting (e.g., inside blockquotes).
func (r *nodeRenderer) writeBlockSeparator(w util.BufWriter) {
r.writeLineSeparator(w)
r.writeLineSeparator(w)
}
// separateFromPrevious writes a block separator if the node has a previous sibling.
func (r *nodeRenderer) separateFromPrevious(w util.BufWriter, n ast.Node) {
if n.PreviousSibling() != nil {
r.writeBlockSeparator(w)
}
}
func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
// The renderer is pooled, so wipe any prefix stack left over from a
// prior document (e.g. one that errored mid-walk and left push/pop
// unbalanced) before starting a fresh convert.
r.prefixes = r.prefixes[:0]
} else {
_, _ = w.WriteString("\n\n")
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, node)
}
_, _ = w.WriteString("*")
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderBlockquote(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, n)
r.prefixes = append(r.prefixes, "> ")
_, _ = w.WriteString("> ")
} else {
r.prefixes = r.prefixes[:len(r.prefixes)-1]
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, n)
// start code block and write code line by line
_, _ = w.WriteString("```\n")
l := n.Lines().Len()
for i := 0; i < l; i++ {
line := n.Lines().At(i)
v := line.Value(source)
_, _ = w.Write(v)
}
} else {
_, _ = w.WriteString("```")
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if node.PreviousSibling() != nil {
r.writeLineSeparator(w)
// another line break if not a nested list item and starting another block
if node.Parent() == nil || node.Parent().Kind() != ast.KindListItem {
r.writeLineSeparator(w)
}
}
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderListItem(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if n.PreviousSibling() != nil {
r.writeLineSeparator(w)
}
parent := n.Parent().(*ast.List)
// compute and write the prefix based on list type and index
var prefixStr string
if parent.IsOrdered() {
index := parent.Start
for c := parent.FirstChild(); c != nil && c != n; c = c.NextSibling() {
index++
}
prefixStr = fmt.Sprintf("%d. ", index)
} else {
prefixStr = "• "
}
_, _ = w.WriteString(prefixStr)
r.prefixes = append(r.prefixes, "\t") // add tab for nested list items
} else {
r.prefixes = r.prefixes[:len(r.prefixes)-1]
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderParagraph(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, n)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderTextBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering && n.PreviousSibling() != nil {
r.writeLineSeparator(w)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderRawHTML(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
n := n.(*ast.RawHTML)
l := n.Segments.Len()
for i := 0; i < l; i++ {
segment := n.Segments.At(i)
_, _ = w.Write(segment.Value(source))
}
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, n)
_, _ = w.WriteString("---")
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.AutoLink)
url := string(n.URL(source))
label := string(n.Label(source))
if n.AutoLinkType == ast.AutoLinkEmail && !strings.HasPrefix(strings.ToLower(url), "mailto:") {
url = "mailto:" + url
}
if url == label {
_, _ = fmt.Fprintf(w, "<%s>", url)
} else {
_, _ = fmt.Fprintf(w, "<%s|%s>", url, label)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
_ = w.WriteByte('`')
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
segment := c.(*ast.Text).Segment
value := segment.Value(source)
if bytes.HasSuffix(value, []byte("\n")) { // replace newline with space
_, _ = w.Write(value[:len(value)-1])
_ = w.WriteByte(' ')
} else {
_, _ = w.Write(value)
}
}
return ast.WalkSkipChildren, nil
}
_ = w.WriteByte('`')
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Emphasis)
mark := "_"
if n.Level == 2 {
mark = "*"
}
_, _ = w.WriteString(mark)
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Link)
if entering {
_, _ = w.WriteString("<")
_, _ = w.Write(util.URLEscape(n.Destination, true))
_, _ = w.WriteString("|")
} else {
_, _ = w.WriteString(">")
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.Image)
_, _ = w.WriteString("<")
_, _ = w.Write(util.URLEscape(n.Destination, true))
_, _ = w.WriteString("|")
// Write the alt text directly
var altBuf bytes.Buffer
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
if textNode, ok := c.(*ast.Text); ok {
altBuf.Write(textNode.Segment.Value(source))
}
}
_, _ = w.Write(altBuf.Bytes())
_, _ = w.WriteString(">")
return ast.WalkSkipChildren, nil
}
func (r *nodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.Text)
segment := n.Segment
value := segment.Value(source)
_, _ = w.Write(value)
if n.HardLineBreak() || n.SoftLineBreak() {
r.writeLineSeparator(w)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
_, _ = w.WriteString("~")
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
r.separateFromPrevious(w, node)
// Collect cells and max widths
var rows [][]string
var colWidths []int
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
if c.Kind() == extensionast.KindTableHeader || c.Kind() == extensionast.KindTableRow {
var row []string
colIdx := 0
for cc := c.FirstChild(); cc != nil; cc = cc.NextSibling() {
if cc.Kind() == extensionast.KindTableCell {
cellText := extractPlainText(cc, source)
row = append(row, cellText)
runeLen := utf8.RuneCountInString(cellText)
if colIdx >= len(colWidths) {
colWidths = append(colWidths, runeLen)
} else if runeLen > colWidths[colIdx] {
colWidths[colIdx] = runeLen
}
colIdx++
}
}
rows = append(rows, row)
}
}
// writing table in code block
_, _ = w.WriteString("```\n")
for i, row := range rows {
for colIdx, cellText := range row {
width := 0
if colIdx < len(colWidths) {
width = colWidths[colIdx]
}
runeLen := utf8.RuneCountInString(cellText)
padding := max(0, width-runeLen)
_, _ = w.WriteString(cellText)
_, _ = w.WriteString(strings.Repeat(" ", padding))
if colIdx < len(row)-1 {
_, _ = w.WriteString(" | ")
}
}
_ = w.WriteByte('\n')
// Print separator after header
if i == 0 {
for colIdx := range row {
width := 0
if colIdx < len(colWidths) {
width = colWidths[colIdx]
}
_, _ = w.WriteString(strings.Repeat("-", width))
if colIdx < len(row)-1 {
_, _ = w.WriteString("-|-")
}
}
_ = w.WriteByte('\n')
}
}
_, _ = w.WriteString("```")
return ast.WalkSkipChildren, nil
}
// extractPlainText extracts all the text content from the given node.
func extractPlainText(n ast.Node, source []byte) string {
var buf bytes.Buffer
_ = ast.Walk(n, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if textNode, ok := node.(*ast.Text); ok {
buf.Write(textNode.Segment.Value(source))
} else if strNode, ok := node.(*ast.String); ok {
buf.Write(strNode.Value)
}
return ast.WalkContinue, nil
})
return strings.TrimSpace(buf.String())
}

View File

@@ -1,115 +0,0 @@
package mrkdwn
import (
"bytes"
"testing"
"github.com/yuin/goldmark"
)
func TestRenderer(t *testing.T) {
tests := []struct {
name string
markdown string
expected string
}{
{
name: "Heading with Thematic Break",
markdown: "# Title 1\n# Hello World\n---\nthis is sometext",
expected: "*Title 1*\n\n*Hello World*\n\n---\n\nthis is sometext\n\n",
},
{
name: "Blockquote",
markdown: "> This is a quote\n> It continues",
expected: "> This is a quote\n> It continues\n\n",
},
{
name: "Fenced Code Block",
markdown: "```go\npackage main\nfunc main() {}\n```",
expected: "```\npackage main\nfunc main() {}\n```\n\n",
},
{
name: "Unordered List",
markdown: "- item 1\n- item 2\n- item 3",
expected: "• item 1\n• item 2\n• item 3\n\n",
},
{
name: "nested unordered list",
markdown: "- item 1\n- item 2\n\t- item 2.1\n\t\t- item 2.1.1\n\t\t- item 2.1.2\n\t- item 2.2\n- item 3",
expected: "• item 1\n• item 2\n\t• item 2.1\n\t\t• item 2.1.1\n\t\t• item 2.1.2\n\t• item 2.2\n• item 3\n\n",
},
{
name: "Ordered List",
markdown: "1. item 1\n2. item 2\n3. item 3",
expected: "1. item 1\n2. item 2\n3. item 3\n\n",
},
{
name: "nested ordered list",
markdown: "1. item 1\n2. item 2\n\t1. item 2.1\n\t\t1. item 2.1.1\n\t\t2. item 2.1.2\n\t2. item 2.2\n\t3. item 2.3\n3. item 3\n4. item 4",
expected: "1. item 1\n2. item 2\n\t1. item 2.1\n\t\t1. item 2.1.1\n\t\t2. item 2.1.2\n\t2. item 2.2\n\t3. item 2.3\n3. item 3\n4. item 4\n\n",
},
{
name: "Links and AutoLinks",
markdown: "This is a [link](https://example.com) and an autolink <https://test.com>",
expected: "This is a <https://example.com|link> and an autolink <https://test.com>\n\n",
},
{
name: "Images",
markdown: "An image ![alt text](https://example.com/image.png)",
expected: "An image <https://example.com/image.png|alt text>\n\n",
},
{
name: "Emphasis",
markdown: "This is **bold** and *italic* and __bold__ and _italic_",
expected: "This is *bold* and _italic_ and *bold* and _italic_\n\n",
},
{
name: "Strikethrough",
markdown: "This is ~~strike~~",
expected: "This is ~strike~\n\n",
},
{
name: "Code Span",
markdown: "This is `inline code` embedded.",
expected: "This is `inline code` embedded.\n\n",
},
{
name: "Table",
markdown: "Col 1 | Col 2 | Col 3\n--- | --- | ---\nVal 1 | Long Value 2 | 3\nShort | V | 1000",
expected: "```\nCol 1 | Col 2 | Col 3\n------|--------------|------\nVal 1 | Long Value 2 | 3 \nShort | V | 1000 \n```\n\n",
},
{
name: "Mixed Nested Lists",
markdown: "1. first\n\t- nested bullet\n\t- another bullet\n2. second",
expected: "1. first\n\t• nested bullet\n\t• another bullet\n2. second\n\n",
},
{
name: "Email AutoLink",
markdown: "<user@example.com>",
expected: "<mailto:user@example.com|user@example.com>\n\n",
},
{
name: "No value string parsed as is",
markdown: "Service: <no value>",
expected: "Service: <no value>\n\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
md := goldmark.New(goldmark.WithExtensions(Extender))
var buf bytes.Buffer
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
t.Fatalf("failed to convert: %v", err)
}
// Do exact string matching
actual := buf.String()
if actual != tt.expected {
t.Errorf("\nExpected:\n%q\nGot:\n%q\nRaw Expected:\n%s\nRaw Got:\n%s",
tt.expected, actual, tt.expected, actual)
}
})
}
}

View File

@@ -1,61 +0,0 @@
package markdownrenderer
import (
"github.com/yuin/goldmark"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
)
// Go text/template writes the literal string "<no value>" when a referenced
// key is missing. The default goldmark HTML path treats it as raw HTML and
// drops it, hiding the missing value. This renderer escapes it so operators
// can see which variable was unset in the rendered notification.
type noValueHTMLRenderer struct {
html.Config
}
func newNoValueHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
r := &noValueHTMLRenderer{Config: html.NewConfig()}
for _, opt := range opts {
opt.SetHTMLOption(&r.Config)
}
return r
}
func (r *noValueHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(gast.KindRawHTML, r.renderRawHTML)
}
func (r *noValueHTMLRenderer) renderRawHTML(w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkSkipChildren, nil
}
n := node.(*gast.RawHTML)
if r.Unsafe {
for i := 0; i < n.Segments.Len(); i++ {
segment := n.Segments.At(i)
_, _ = w.Write(segment.Value(source))
}
return gast.WalkSkipChildren, nil
}
if string(n.Segments.Value(source)) == "<no value>" {
_, _ = w.WriteString("&lt;no value&gt;")
return gast.WalkSkipChildren, nil
}
_, _ = w.WriteString("<!-- raw HTML omitted -->")
return gast.WalkSkipChildren, nil
}
type escapeNoValueExtension struct{}
// escapeNoValue renders the literal `<no value>` produced by Go's text/template
// as visible escaped text instead of dropping it as raw HTML.
var escapeNoValue = &escapeNoValueExtension{}
func (e *escapeNoValueExtension) Extend(m goldmark.Markdown) {
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(newNoValueHTMLRenderer(), 500),
))
}

View File

@@ -1,66 +0,0 @@
package markdownrenderer
import (
"bytes"
"testing"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
)
func TestEscapeNoValue(t *testing.T) {
tests := []struct {
name string
markdown string
expected string
}{
{
name: "plain text",
markdown: "Service: <no value>",
expected: "<p>Service: &lt;no value&gt;</p>\n",
},
{
name: "inside strong",
markdown: "Service: **<no value>**",
expected: "<p>Service: <strong>&lt;no value&gt;</strong></p>\n",
},
{
name: "inside emphasis",
markdown: "Service: *<no value>*",
expected: "<p>Service: <em>&lt;no value&gt;</em></p>\n",
},
{
name: "inside strikethrough",
markdown: "Service: ~~<no value>~~",
expected: "<p>Service: <del>&lt;no value&gt;</del></p>\n",
},
{
name: "real html still omitted",
markdown: "hello <div>world</div>",
expected: "<p>hello <!-- raw HTML omitted -->world<!-- raw HTML omitted --></p>\n",
},
{
name: "inside heading",
markdown: "# Title <no value>",
expected: "<h1>Title &lt;no value&gt;</h1>\n",
},
{
name: "inside list item",
markdown: "- item <no value>",
expected: "<ul>\n<li>item &lt;no value&gt;</li>\n</ul>\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gm := goldmark.New(goldmark.WithExtensions(escapeNoValue, extension.Strikethrough))
var buf bytes.Buffer
if err := gm.Convert([]byte(tt.markdown), &buf); err != nil {
t.Fatal(err)
}
if buf.String() != tt.expected {
t.Errorf("expected:\n%s\ngot:\n%s", tt.expected, buf.String())
}
})
}
}

View File

@@ -1,145 +0,0 @@
package markdownrenderer
import (
"encoding/json"
"testing"
)
func jsonEqual(a, b string) bool {
var va, vb any
if err := json.Unmarshal([]byte(a), &va); err != nil {
return false
}
if err := json.Unmarshal([]byte(b), &vb); err != nil {
return false
}
ja, _ := json.Marshal(va)
jb, _ := json.Marshal(vb)
return string(ja) == string(jb)
}
func prettyJSON(s string) string {
var v any
if err := json.Unmarshal([]byte(s), &v); err != nil {
return s
}
b, _ := json.MarshalIndent(v, "", " ")
return string(b)
}
func TestRenderSlackBlockKit(t *testing.T) {
tests := []struct {
name string
markdown string
expected string
}{
{
name: "simple paragraph",
markdown: "Hello world",
expected: `[
{
"type": "section",
"text": { "type": "mrkdwn", "text": "Hello world" }
}
]`,
},
{
name: "alert-themed with heading, list, and code block",
markdown: `# Alert Triggered
- Service: **checkout-api**
- Status: _critical_
` + "```" + `
error: connection timeout after 30s
` + "```",
expected: `[
{
"type": "section",
"text": { "type": "mrkdwn", "text": "*Alert Triggered*" }
},
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [
{ "type": "text", "text": "Service: " },
{ "type": "text", "text": "checkout-api", "style": { "bold": true } }
]},
{ "type": "rich_text_section", "elements": [
{ "type": "text", "text": "Status: " },
{ "type": "text", "text": "critical", "style": { "italic": true } }
]}
]
}
]
},
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_preformatted",
"border": 0,
"elements": [
{ "type": "text", "text": "error: connection timeout after 30s" }
]
}
]
}
]`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := RenderSlackBlockKit(tt.markdown)
if err != nil {
t.Fatalf("Render error: %v", err)
}
if !json.Valid([]byte(got)) {
t.Fatalf("output is not valid JSON:\n%s", got)
}
if !jsonEqual(got, tt.expected) {
t.Errorf("JSON mismatch\n\nMarkdown:\n%s\n\nExpected:\n%s\n\nGot:\n%s",
tt.markdown, prettyJSON(tt.expected), prettyJSON(got))
}
})
}
}
func TestRenderSlackMrkdwn(t *testing.T) {
markdown := `# Alert Triggered
- Service: **checkout-api**
- Status: _critical_
- Dashboard: [View Dashboard](https://example.com/dashboard)
| Metric | Value | Threshold |
| --- | --- | --- |
| Latency | 250ms | 100ms |
| Error Rate | 5.2% | 1% |
` + "```" + `
error: connection timeout after 30s
` + "```"
expected := "*Alert Triggered*\n\n" +
"• Service: *checkout-api*\n" +
"• Status: _critical_\n" +
"• Dashboard: <https://example.com/dashboard|View Dashboard>\n\n" +
"```\nMetric | Value | Threshold\n-----------|-------|----------\nLatency | 250ms | 100ms \nError Rate | 5.2% | 1% \n```\n\n" +
"```\nerror: connection timeout after 30s\n```\n\n"
got, err := RenderSlackMrkdwn(markdown)
if err != nil {
t.Fatalf("Render error: %v", err)
}
if got != expected {
t.Errorf("mrkdwn mismatch\n\nExpected:\n%q\n\nGot:\n%q", expected, got)
}
}

View File

@@ -26,6 +26,11 @@ var (
errInvalidServiceAccountName = errors.New(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name must start with a lowercase letter (a-z), contain only lowercase letters, numbers (0-9), and hyphens (-), and be at most 50 characters long")
)
var (
TypeableMetaResourceServiceAccount = authtypes.MustNewTypeableMetaResource(authtypes.MustNewName("service-account"))
TypeableMetaResourcesServiceAccounts = authtypes.MustNewTypeableMetaResources(authtypes.MustNewName("service-accounts"))
)
var (
ServiceAccountStatusActive = ServiceAccountStatus{valuer.NewString("active")}
ServiceAccountStatusDeleted = ServiceAccountStatus{valuer.NewString("deleted")}

View File

@@ -1,83 +0,0 @@
// Package templatingtypes holds the data types produced by the markdown
// renderers under pkg/templating (e.g. Slack Block Kit JSON DTOs).
package templatingtypes
// SectionBlock represents a Slack section block with mrkdwn text.
type SectionBlock struct {
Type string `json:"type"`
Text *TextObject `json:"text"`
}
// DividerBlock represents a Slack divider block.
type DividerBlock struct {
Type string `json:"type"`
}
// RichTextBlock is a container for rich text elements (lists, code blocks,
// table and cell blocks).
type RichTextBlock struct {
Type string `json:"type"`
Elements []any `json:"elements"`
}
// TableBlock represents a Slack table rendered as a rich_text block with
// preformatted text.
type TableBlock struct {
Type string `json:"type"`
Rows [][]TableCell `json:"rows"`
}
// TableCell is a cell in a TableBlock.
type TableCell struct {
Type string `json:"type"`
Elements []any `json:"elements"`
}
// TextObject is the text field inside a SectionBlock.
type TextObject struct {
Type string `json:"type"`
Text string `json:"text"`
}
// RichTextList represents an ordered or unordered list.
type RichTextList struct {
Type string `json:"type"`
Style string `json:"style"`
Indent int `json:"indent"`
Border int `json:"border"`
Offset int `json:"offset,omitempty"`
Elements []any `json:"elements"`
}
// RichTextPreformatted represents a code block.
type RichTextPreformatted struct {
Type string `json:"type"`
Elements []any `json:"elements"`
Border int `json:"border"`
Language string `json:"language,omitempty"`
}
// RichTextInline represents inline text with optional styling, e.g. text
// inside a list or a table cell.
type RichTextInline struct {
Type string `json:"type"`
Text string `json:"text"`
Style *RichTextStyle `json:"style,omitempty"`
}
// RichTextLink represents a link inside rich text, e.g. a link inside a list
// or a table cell.
type RichTextLink struct {
Type string `json:"type"`
URL string `json:"url"`
Text string `json:"text,omitempty"`
Style *RichTextStyle `json:"style,omitempty"`
}
// RichTextStyle toggles inline styling on a rich-text element.
type RichTextStyle struct {
Bold bool `json:"bold,omitempty"`
Italic bool `json:"italic,omitempty"`
Strike bool `json:"strike,omitempty"`
Code bool `json:"code,omitempty"`
}