Skip to content

Commit 6e313e7

Browse files
marefrkylebrandtaknuds1oddlittlebird
authored
Image Rendering: Remove PhantomJS support (#23460)
Removes all references and usage of PhantomJS #23375. Remove direct link rendered image e2e smoke test for now. Docker: Fix installing chrome in ubuntu custom docker image. Improve handling of image renderer not available/installed #23593. Add PhantomJS breaking change and upgrading notes. Use grabpl v0.2.10. Closes #13802 Co-authored-by: Kyle Brandt <[email protected]> Co-authored-by: Arve Knudsen <[email protected]> Co-authored-by: Diana Payton <[email protected]>
1 parent 0a1ab60 commit 6e313e7

File tree

38 files changed

+108
-479
lines changed

38 files changed

+108
-479
lines changed

.circleci/config.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ commands:
5050
- run:
5151
name: "Install Grafana build pipeline tool"
5252
command: |
53-
VERSION=0.2.9
53+
VERSION=0.2.10
5454
curl -fLO https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v${VERSION}/grabpl
5555
chmod +x grabpl
5656
mv grabpl /tmp

CHANGELOG.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# 7.0.0 (unreleased)
2+
3+
## Breaking changes
4+
5+
- **Removed PhantomJS**: PhantomJS was deprecated in [Grafana v6.4](https://grafana.com/docs/grafana/latest/guides/whats-new-in-v6-4/#phantomjs-deprecation) and starting from Grafana v7.0.0, all PhantomJS support has been removed. This means that Grafana no longer ships with a built-in image renderer, and we advise you to install the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer).
6+
17
# 6.7.2 (2020-04-02)
28

39
### Bug Fixes
@@ -6,7 +12,7 @@
612
- **Dashboard**: Fixed issue with saving new dashboard after changing title . [#23104](https://github.com/grafana/grafana/pull/23104), [@dprokop](https://github.com/dprokop)
713
- **DataLinks**: make sure we use the correct datapoint when dataset contains null value.. [#22981](https://github.com/grafana/grafana/pull/22981), [@mckn](https://github.com/mckn)
814
- **Plugins**: Fixed issue for plugins that imported dateMath util . [#23069](https://github.com/grafana/grafana/pull/23069), [@mckn](https://github.com/mckn)
9-
- **Security**: Fix for dashboard snapshot original dashboard link could contain XSS vulnerability in url. [#23254](https://github.com/grafana/grafana/pull/23254), [@torkelo](https://github.com/torkelo). Big thanks to Ahmed A. Sherif for reporting this issue.
15+
- **Security**: Fix for dashboard snapshot original dashboard link could contain XSS vulnerability in url. [#23254](https://github.com/grafana/grafana/pull/23254), [@torkelo](https://github.com/torkelo). Big thanks to Ahmed A. Sherif for reporting this issue.
1016
- **Variables**: Fixes issue with too many queries being issued for nested template variables after value change. [#23220](https://github.com/grafana/grafana/pull/23220), [@torkelo](https://github.com/torkelo)
1117
- **Plugins**: Expose promiseToDigest. [#23249](https://github.com/grafana/grafana/pull/23249), [@torkelo](https://github.com/torkelo)
1218
- **Reporting (Enterprise)**: Fixes issue updating a report created by someone else

Dockerfile

-19
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,6 @@ RUN go run build.go build
1818
# Node build container
1919
FROM node:12.13.0-alpine
2020

21-
# PhantomJS
22-
RUN apk add --no-cache curl &&\
23-
cd /tmp && curl -Ls https://github.com/dustinblackman/phantomized/releases/download/2.1.1/dockerized-phantomjs.tar.gz | tar xz &&\
24-
cp -R lib lib64 / &&\
25-
cp -R usr/lib/x86_64-linux-gnu /usr/lib &&\
26-
cp -R usr/share /usr/share &&\
27-
cp -R etc/fonts /etc &&\
28-
curl -k -Ls https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 | tar -jxf - &&\
29-
cp phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/local/bin/phantomjs
30-
3121
WORKDIR /usr/src/app/
3222

3323
COPY package.json yarn.lock ./
@@ -80,18 +70,9 @@ RUN mkdir -p "$GF_PATHS_HOME/.aws" && \
8070
chown -R grafana:grafana "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" && \
8171
chmod -R 777 "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING"
8272

83-
# PhantomJS
84-
COPY --from=1 /tmp/lib /lib
85-
COPY --from=1 /tmp/lib64 /lib64
86-
COPY --from=1 /tmp/usr/lib/x86_64-linux-gnu /usr/lib/x86_64-linux-gnu
87-
COPY --from=1 /tmp/usr/share /usr/share
88-
COPY --from=1 /tmp/etc/fonts /etc/fonts
89-
COPY --from=1 /usr/local/bin/phantomjs /usr/local/bin
90-
9173
COPY --from=0 /go/src/github.com/grafana/grafana/bin/linux-amd64/grafana-server /go/src/github.com/grafana/grafana/bin/linux-amd64/grafana-cli ./bin/
9274
COPY --from=1 /usr/src/app/public ./public
9375
COPY --from=1 /usr/src/app/tools ./tools
94-
COPY tools/phantomjs/render.js ./tools/phantomjs/render.js
9576

9677
EXPOSE 3000
9778

Dockerfile.ubuntu

+2-10
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,6 @@ RUN go run build.go build
1414

1515
FROM node:12.13 AS js-builder
1616

17-
# PhantomJS
18-
RUN apt-get update && apt-get install -y curl &&\
19-
curl -L https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 | tar xj &&\
20-
cp phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/local/bin/phantomjs
21-
2217
WORKDIR /usr/src/app/
2318

2419
COPY package.json yarn.lock ./
@@ -54,8 +49,8 @@ WORKDIR $GF_PATHS_HOME
5449

5550
COPY conf conf
5651

57-
# We need font libs for phantomjs, and curl should be part of the image
58-
RUN apt-get update && apt-get upgrade -y && apt-get install -y ca-certificates libfontconfig1 curl
52+
# curl should be part of the image
53+
RUN apt-get update && apt-get upgrade -y && apt-get install -y ca-certificates curl
5954

6055
RUN mkdir -p "$GF_PATHS_HOME/.aws" && \
6156
addgroup --system --gid $GF_GID grafana && \
@@ -71,14 +66,11 @@ RUN mkdir -p "$GF_PATHS_HOME/.aws" && \
7166
chown -R grafana:grafana "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" && \
7267
chmod -R 777 "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING"
7368

74-
# PhantomJS
75-
COPY --from=js-builder /usr/local/bin/phantomjs /usr/local/bin/
7669

7770
COPY --from=go-builder /src/grafana/bin/linux-amd64/grafana-server /src/grafana/bin/linux-amd64/grafana-cli bin/
7871
COPY --from=js-builder /usr/src/app/public public
7972
COPY --from=js-builder /usr/src/app/tools tools
8073

81-
COPY tools/phantomjs/render.js tools/phantomjs/
8274
COPY packaging/docker/run.sh /
8375

8476
USER grafana

Gruntfile.js

-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ module.exports = function (grunt) {
3535
config.libc = grunt.option('libc');
3636
}
3737

38-
config.phjs = grunt.option('phjsToRelease');
3938
config.pkg.version = grunt.option('pkgVer') || config.pkg.version;
4039

4140
console.log('Version', config.pkg.version);

build.go

-5
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ var (
4040
linuxPackageVersion string = "v1"
4141
linuxPackageIteration string = ""
4242
race bool
43-
phjsToRelease string
4443
workingDir string
4544
includeBuildId bool = true
4645
buildId string = "0"
@@ -69,7 +68,6 @@ func main() {
6968
flag.StringVar(&libc, "libc", "", "LIBC")
7069
flag.BoolVar(&cgo, "cgo-enabled", cgo, "Enable cgo")
7170
flag.StringVar(&pkgArch, "pkg-arch", "", "PKG ARCH")
72-
flag.StringVar(&phjsToRelease, "phjs", "", "PhantomJS binary")
7371
flag.BoolVar(&race, "race", race, "Use race detector")
7472
flag.BoolVar(&modVendor, "modVendor", modVendor, "Go modules use vendor folder")
7573
flag.BoolVar(&includeBuildId, "includeBuildId", includeBuildId, "IncludeBuildId in package name")
@@ -459,9 +457,6 @@ func gruntBuildArg(task string) []string {
459457
if libc != "" {
460458
args = append(args, fmt.Sprintf("--libc=%s", libc))
461459
}
462-
if phjsToRelease != "" {
463-
args = append(args, fmt.Sprintf("--phjsToRelease=%v", phjsToRelease))
464-
}
465460
if enterprise {
466461
args = append(args, "--enterprise")
467462
}

docs/sources/administration/image_rendering.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ The following example describes how to build and run the remote HTTP rendering s
108108

109109
## PhantomJS
110110

111-
> PhantomJS is deprecated since Grafana v6.4 and will be removed in a future release. Please migrate to the Grafana Image Renderer plugin or remote rendering service.
111+
> Starting from Grafana v7.0.0, all PhantomJS support has been removed. Please use the Grafana Image Renderer plugin or remote rendering service.
112112

113113
## Troubleshoot image rendering
114114

docs/sources/installation/upgrading.md

+6
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,9 @@ Due to this change in Chrome, the `[security]` setting `cookie_samesite` configu
238238

239239
This version of Chrome also rejects insecure `SameSite=None` cookies. See https://www.chromestatus.com/feature/5633521622188032 for more information. Make sure that you
240240
change the `[security]` setting `cookie_secure` to `true` and use HTTPS when `cookie_samesite` is configured to `none`, otherwise authentication in Grafana won't work properly.
241+
242+
## Upgrading to v7.0
243+
244+
### PhantomJS removed
245+
246+
PhantomJS was deprecated in [Grafana v6.4](https://grafana.com/docs/grafana/latest/guides/whats-new-in-v6-4/#phantomjs-deprecation) and starting from Grafana v7.0.0, all PhantomJS support has been removed. This means that Grafana no longer ships with a built-in image renderer, and we adwise you to install the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer).
Binary file not shown.

e2e/suite1/specs/smoketests.spec.ts

-2
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ e2e.scenario({
2121
.contains('Show')
2222
.click();
2323

24-
e2e.pages.Components.BackButton.backArrow().click();
25-
2624
// e2e.pages.Dashboard.Panels.Panel.title('Panel Title').click();
2725
// e2e.pages.Dashboard.Panels.Panel.headerItems('Inspect').click();
2826
},

package.json

-1
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,6 @@
172172
"ngtemplate-loader": "2.0.1",
173173
"node-sass": "4.13.1",
174174
"optimize-css-assets-webpack-plugin": "5.0.3",
175-
"phantomjs-prebuilt": "2.1.16",
176175
"pixelmatch": "5.1.0",
177176
"pngjs": "3.4.0",
178177
"postcss-browser-reporter": "0.6.0",

packages/grafana-runtime/src/config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export class GrafanaBootConfig {
104104
tracingIntegration: false,
105105
};
106106
licenseInfo: LicenseInfo = {} as LicenseInfo;
107-
phantomJSRenderer = false;
107+
rendererAvailable = false;
108108

109109
constructor(options: GrafanaBootConfig) {
110110
this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark);

packaging/docker/Dockerfile

-13
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,6 @@ RUN if [ `arch` = "x86_64" ]; then \
4141
rm -f /etc/ld.so.cache; \
4242
fi
4343

44-
# PhantomJS
45-
RUN if [ `arch` = "x86_64" ]; then \
46-
apk add --no-cache --virtual phantomjs-utils curl && \
47-
cd /tmp && \
48-
curl -Ls https://github.com/dustinblackman/phantomized/releases/download/2.1.1/dockerized-phantomjs.tar.gz | tar xz && \
49-
cp -R lib lib64 / && \
50-
cp -R usr/lib/x86_64-linux-gnu /usr/lib && \
51-
cp -R usr/share/fonts /usr/share && \
52-
cp -R etc/fonts /etc && \
53-
rm -rf /tmp/* && \
54-
apk del --no-cache phantomjs-utils; \
55-
fi
56-
5744
COPY --from=0 /tmp/grafana "$GF_PATHS_HOME"
5845

5946
RUN mkdir -p "$GF_PATHS_HOME/.aws" && \

packaging/docker/custom/ubuntu.Dockerfile

+6-4
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,17 @@ RUN mkdir -p "$GF_PATHS_PLUGINS" && \
1717
RUN if [ $GF_INSTALL_IMAGE_RENDERER_PLUGIN = "true" ]; then \
1818
apt-get update && \
1919
apt-get upgrade -y && \
20-
apt-get install -y chromium-browser && \
20+
apt-get install -y gdebi-core && \
21+
cd /tmp && \
22+
curl -LO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && \
23+
gdebi --n google-chrome-stable_current_amd64.deb && \
2124
apt-get autoremove -y && \
22-
rm -rf /var/lib/apt/lists/* && \
23-
rm -rf /usr/share/grafana/tools/phantomjs; \
25+
rm -rf /var/lib/apt/lists/*; \
2426
fi
2527

2628
USER grafana
2729

28-
ENV GF_RENDERER_PLUGIN_CHROME_BIN="/usr/bin/chromium-browser"
30+
ENV GF_RENDERER_PLUGIN_CHROME_BIN="/usr/bin/google-chrome"
2931

3032
RUN if [ $GF_INSTALL_IMAGE_RENDERER_PLUGIN = "true" ]; then \
3133
grafana-cli \

packaging/docker/ubuntu.Dockerfile

+3-8
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,9 @@ ENV PATH=/usr/share/grafana/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bi
2727
WORKDIR $GF_PATHS_HOME
2828

2929
# Install dependencies
30-
# We need curl in the image, and if the architecture is x86-64, we need to install libfontconfig1 for PhantomJS
31-
RUN if [ `arch` = "x86_64" ]; then \
32-
apt-get update && apt-get upgrade -y && apt-get install -y ca-certificates libfontconfig1 curl && \
33-
apt-get autoremove -y && rm -rf /var/lib/apt/lists/*; \
34-
else \
35-
apt-get update && apt-get upgrade -y && apt-get install -y ca-certificates curl && \
36-
apt-get autoremove -y && rm -rf /var/lib/apt/lists/*; \
37-
fi
30+
# We need curl in the image
31+
RUN apt-get update && apt-get upgrade -y && apt-get install -y ca-certificates curl && \
32+
apt-get autoremove -y && rm -rf /var/lib/apt/lists/*;
3833

3934
COPY --from=grafana-builder /tmp/grafana "$GF_PATHS_HOME"
4035

pkg/api/frontendsettings.go

+1-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"strconv"
55

66
"github.com/grafana/grafana/pkg/models"
7-
"github.com/grafana/grafana/pkg/services/rendering"
87

98
"github.com/grafana/grafana/pkg/components/simplejson"
109
"github.com/grafana/grafana/pkg/util"
@@ -212,7 +211,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i
212211
"licenseUrl": hs.License.LicenseURL(c.SignedInUser),
213212
},
214213
"featureToggles": hs.Cfg.FeatureToggles,
215-
"phantomJSRenderer": rendering.IsPhantomJSEnabled,
214+
"rendererAvailable": hs.RenderService.IsAvailable(),
216215
}
217216

218217
return jsonObj, nil

pkg/api/index.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat
4242
appURL := setting.AppUrl
4343
appSubURL := setting.AppSubUrl
4444

45-
// special case when doing localhost call from phantomjs
45+
// special case when doing localhost call from image renderer
4646
if c.IsRenderCall && !hs.Cfg.ServeFromSubPath {
4747
appURL = fmt.Sprintf("%s://localhost:%s", setting.Protocol, setting.HttpPort)
4848
appSubURL = ""

pkg/services/alerting/notifier.go

+18-12
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,25 @@ func (n *notificationService) SendIfNeeded(evalCtx *EvalContext) error {
5353
}
5454

5555
if notifierStates.ShouldUploadImage() {
56-
// Create a copy of EvalContext and give it a new, shorter, timeout context to upload the image
57-
uploadEvalCtx := *evalCtx
58-
timeout := setting.AlertingNotificationTimeout / 2
59-
var uploadCtxCancel func()
60-
uploadEvalCtx.Ctx, uploadCtxCancel = context.WithTimeout(evalCtx.Ctx, timeout)
61-
62-
// Try to upload the image without consuming all the time allocated for EvalContext
63-
if err = n.renderAndUploadImage(&uploadEvalCtx, timeout); err != nil {
64-
n.log.Error("Failed to render and upload alert panel image.", "ruleId", uploadEvalCtx.Rule.ID, "error", err)
56+
if n.renderService.IsAvailable() {
57+
// Create a copy of EvalContext and give it a new, shorter, timeout context to upload the image
58+
uploadEvalCtx := *evalCtx
59+
timeout := setting.AlertingNotificationTimeout / 2
60+
var uploadCtxCancel func()
61+
uploadEvalCtx.Ctx, uploadCtxCancel = context.WithTimeout(evalCtx.Ctx, timeout)
62+
63+
// Try to upload the image without consuming all the time allocated for EvalContext
64+
if err = n.renderAndUploadImage(&uploadEvalCtx, timeout); err != nil {
65+
n.log.Error("Failed to render and upload alert panel image.", "ruleId", uploadEvalCtx.Rule.ID, "error", err)
66+
}
67+
uploadCtxCancel()
68+
evalCtx.ImageOnDiskPath = uploadEvalCtx.ImageOnDiskPath
69+
evalCtx.ImagePublicURL = uploadEvalCtx.ImagePublicURL
70+
} else {
71+
n.log.Warn("Could not render image for alert notification, no image renderer found/installed. " +
72+
"For image rendering support please install the grafana-image-renderer plugin. " +
73+
"Read more at https://grafana.com/docs/grafana/latest/administration/image_rendering/")
6574
}
66-
uploadCtxCancel()
67-
evalCtx.ImageOnDiskPath = uploadEvalCtx.ImageOnDiskPath
68-
evalCtx.ImagePublicURL = uploadEvalCtx.ImagePublicURL
6975
}
7076

7177
return n.sendNotifications(evalCtx, notifierStates)

pkg/services/alerting/notifier_test.go

+25
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ func TestNotificationService(t *testing.T) {
3939
require.Truef(t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
4040
})
4141

42+
notificationServiceScenario(t, "Given alert rule with upload image enabled but no renderer available should not render and upload image, but send notification", evalCtx, true, func(scenarioCtx *scenarioContext) {
43+
scenarioCtx.rendererAvailable = false
44+
err := scenarioCtx.notificationService.SendIfNeeded(evalCtx)
45+
require.NoError(t, err)
46+
47+
require.Equalf(t, 0, scenarioCtx.renderCount, "expected render to not be called, but it was")
48+
require.Equalf(t, 0, scenarioCtx.imageUploadCount, "expected image to not be uploaded, but it was")
49+
require.Truef(t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
50+
})
51+
4252
notificationServiceScenario(t, "Given alert rule with upload image disabled should not render and upload image, but send notification", evalCtx, false, func(scenarioCtx *scenarioContext) {
4353
err := scenarioCtx.notificationService.SendIfNeeded(evalCtx)
4454
require.NoError(t, err)
@@ -114,6 +124,7 @@ type scenarioContext struct {
114124
renderCount int
115125
uploadProvider func(ctx context.Context, path string) (string, error)
116126
renderProvider func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error)
127+
rendererAvailable bool
117128
}
118129

119130
type scenarioFunc func(c *scenarioContext)
@@ -197,7 +208,12 @@ func notificationServiceScenario(t *testing.T, name string, evalCtx *EvalContext
197208
return &rendering.RenderResult{FilePath: "image.png"}, nil
198209
}
199210

211+
scenarioCtx.rendererAvailable = true
212+
200213
renderService := &testRenderService{
214+
isAvailableProvider: func() bool {
215+
return scenarioCtx.rendererAvailable
216+
},
201217
renderProvider: func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error) {
202218
if scenarioCtx.renderProvider != nil {
203219
if _, err := scenarioCtx.renderProvider(ctx, opts); err != nil {
@@ -286,10 +302,19 @@ func (n *testNotifier) GetFrequency() time.Duration {
286302
var _ Notifier = &testNotifier{}
287303

288304
type testRenderService struct {
305+
isAvailableProvider func() bool
289306
renderProvider func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error)
290307
renderErrorImageProvider func(error error) (*rendering.RenderResult, error)
291308
}
292309

310+
func (s *testRenderService) IsAvailable() bool {
311+
if s.isAvailableProvider != nil {
312+
return s.isAvailableProvider()
313+
}
314+
315+
return true
316+
}
317+
293318
func (s *testRenderService) Render(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error) {
294319
if s.renderProvider != nil {
295320
return s.renderProvider(ctx, opts)

pkg/services/rendering/interface.go

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type RenderResult struct {
3232
type renderFunc func(ctx context.Context, renderKey string, options Opts) (*RenderResult, error)
3333

3434
type Service interface {
35+
IsAvailable() bool
3536
Render(ctx context.Context, opts Opts) (*RenderResult, error)
3637
RenderErrorImage(error error) (*RenderResult, error)
3738
GetRenderUser(key string) (*RenderUser, bool)

0 commit comments

Comments
 (0)