mirror of
https://github.com/zitadel/zitadel.git
synced 2024-12-04 23:45:07 +00:00
docs: add pylon framework to examples (#8115)
# Which Problems Are Solved
- Lack of documentation on integrating the Pylon framework with ZITADEL
# How the Problems Are Solved
- Adds examples to the ZITADEL documentation on how to integrate with
the Pylon framework.
- Provides clear, step-by-step instructions and code snippets for
seamless integration.
# Additional Changes
- Updates some formatting related issues. This includes changes to
trailing semicolons and array newlines in two or three instances without
significantly altering the previous formatting.
5b23416a8c
# Additional Context
Add the pylon framework to the ZITADEL documentation examples as
previously discussed with @fforootd.
- [Pylon](https://github.com/getcronit/pylon)
- [Pylon Documentation](https://pylon.cronit.io)
---------
Co-authored-by: Tim Möhlmann <tim+github@zitadel.com>
This commit is contained in:
parent
18222008b6
commit
ca69ba41ee
1
docs/docs/examples/imports/_setup_pylon.mdx
Normal file
1
docs/docs/examples/imports/_setup_pylon.mdx
Normal file
@ -0,0 +1 @@
|
|||||||
|
You have to install Pylon as described in [their documentation](https://pylon.cronit.io/docs/installation/).
|
304
docs/docs/examples/secure-api/pylon.mdx
Normal file
304
docs/docs/examples/secure-api/pylon.mdx
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
---
|
||||||
|
title: ZITADEL with Pylon
|
||||||
|
sidebar_label: Pylon
|
||||||
|
---
|
||||||
|
|
||||||
|
import AppJWT from "../imports/_app_jwt.mdx";
|
||||||
|
import ServiceuserJWT from "../imports/_serviceuser_jwt.mdx";
|
||||||
|
import ServiceuserRole from "../imports/_serviceuser_role.mdx";
|
||||||
|
import SetupPylon from "../imports/_setup_pylon.mdx";
|
||||||
|
|
||||||
|
This integration guide demonstrates the recommended way to incorporate ZITADEL into your Pylon service.
|
||||||
|
It explains how to check the token validity in the API and how to check for permissions.
|
||||||
|
|
||||||
|
By the end of this guide, your application will have three different endpoint which are public, private(valid token) and private-scoped(valid token with specific role).
|
||||||
|
|
||||||
|
## ZITADEL setup
|
||||||
|
|
||||||
|
Before we can start building our application, we have to do a few configuration steps in ZITADEL Console.
|
||||||
|
|
||||||
|
### Create application
|
||||||
|
|
||||||
|
<AppJWT />
|
||||||
|
|
||||||
|
### Create Serviceuser
|
||||||
|
|
||||||
|
<ServiceuserJWT />
|
||||||
|
|
||||||
|
### Give Serviceuser an authorization
|
||||||
|
|
||||||
|
<ServiceuserRole />
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
At the end you should have the following for the API:
|
||||||
|
|
||||||
|
- Issuer, something like `https://example.zitadel.cloud` or `http://localhost:8080`
|
||||||
|
- `.json`-key-file for the API, from the application
|
||||||
|
- ID of the project
|
||||||
|
|
||||||
|
And the following from the Serviceuser:
|
||||||
|
|
||||||
|
- `.json`-key-file from the serviceuser
|
||||||
|
|
||||||
|
## Setup new Pylon service
|
||||||
|
|
||||||
|
### Setup Pylon
|
||||||
|
|
||||||
|
<SetupPylon />
|
||||||
|
|
||||||
|
### Creating a new project
|
||||||
|
|
||||||
|
To create a new Pylon project, run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pylon new my-pylon-project
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create a new directory called `my-pylon-project` with a basic Pylon project structure.
|
||||||
|
|
||||||
|
### Project structure
|
||||||
|
|
||||||
|
Pylon projects are structured as follows:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-pylon-project/
|
||||||
|
├── .pylon/
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.pylon/`: Contains the production build of your project.
|
||||||
|
- `src/`: Contains the source code of your project.
|
||||||
|
- `src/index.ts`: The entry point of your Pylon service.
|
||||||
|
- `package.json`: The npm package configuration file.
|
||||||
|
- `tsconfig.json`: The TypeScript configuration file.
|
||||||
|
|
||||||
|
### Basic example
|
||||||
|
|
||||||
|
Here's an example of a basic Pylon service:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineService } from "@getcronit/pylon";
|
||||||
|
|
||||||
|
export default defineService({
|
||||||
|
Query: {
|
||||||
|
sum: (a: number, b: number) => a + b,
|
||||||
|
},
|
||||||
|
Mutation: {
|
||||||
|
divide: (a: number, b: number) => a / b,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Secure the API
|
||||||
|
|
||||||
|
### Add ZITADEL info to the service
|
||||||
|
|
||||||
|
1. Create a `.env` file in the root folder of your project and add the following configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUTH_ISSUER='URL to the zitadel instance'
|
||||||
|
AUTH_PROJECT_ID='ID of the project'
|
||||||
|
```
|
||||||
|
|
||||||
|
It should look something like this:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUTH_ISSUER='https://example.zitadel.cloud'
|
||||||
|
AUTH_PROJECT_ID='250719519163548112'
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Copy the `.json`-key-file that you downloaded from the ZITADEL Console into the root folder of your project and rename it to `key.json`.
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
Pylon provides a auth module and a decorator to check the validity of the token and the permissions.
|
||||||
|
|
||||||
|
- `auth.initialize()`: Initializes the authentication middleware.
|
||||||
|
- `auth.require()` : Middleware to check if the token is valid.
|
||||||
|
- `auth.require({roles: ['role']})`: Middleware to check if the token is valid and has the specified roles.
|
||||||
|
- `requireAuth()`: Decorator to check if the token is valid.
|
||||||
|
- `requireAuth({roles: ['role']})`: Decorator to check if the token is valid and has the specified roles.
|
||||||
|
|
||||||
|
### Build the Pylon service
|
||||||
|
|
||||||
|
Now we will create a new Pylon service with the following endpoints:
|
||||||
|
|
||||||
|
- `/api/public`: Public endpoint
|
||||||
|
- `/api/private`: Private endpoint
|
||||||
|
- `/api/private-scoped`: Private endpoint with specific role
|
||||||
|
- `/graphql`: GraphQL endpoint
|
||||||
|
- Query: `me`: Private endpoint that returns the current user and the messages if the role is `read:messages`
|
||||||
|
- Query: `info`: Public endpoint
|
||||||
|
|
||||||
|
### Create the service
|
||||||
|
|
||||||
|
The following code demonstrates how to create a Pylon service with the required endpoints, it must be added to the `src/index.ts` file of your project:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
defineService,
|
||||||
|
PylonAPI,
|
||||||
|
auth,
|
||||||
|
requireAuth,
|
||||||
|
getContext,
|
||||||
|
ServiceError,
|
||||||
|
} from "@getcronit/pylon";
|
||||||
|
|
||||||
|
class User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
#messages: string[];
|
||||||
|
|
||||||
|
constructor(id: string, name: string, messages: string[]) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.#messages = messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireAuth({ roles: ["read:messages"] })
|
||||||
|
async messages() {
|
||||||
|
return this.#messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
static users: User[] = [];
|
||||||
|
|
||||||
|
@requireAuth()
|
||||||
|
static async me() {
|
||||||
|
const ctx = getContext();
|
||||||
|
const id = ctx.get("auth")!.sub;
|
||||||
|
|
||||||
|
const user = User.users.find((user) => user.id === id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ServiceError("User not found", {
|
||||||
|
statusCode: 404,
|
||||||
|
code: "USER_NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireAuth()
|
||||||
|
static async create() {
|
||||||
|
const ctx = getContext();
|
||||||
|
|
||||||
|
const auth = ctx.get("auth")!;
|
||||||
|
|
||||||
|
// Check if the user already exists
|
||||||
|
|
||||||
|
if (User.users.find((user) => user.id === auth.sub)) {
|
||||||
|
throw new ServiceError("User already exists", {
|
||||||
|
statusCode: 400,
|
||||||
|
code: "USER_ALREADY_EXISTS",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = new User(auth.sub, auth.username || "unknown", [
|
||||||
|
"Welcome to Pylon with ZITADEL!",
|
||||||
|
]);
|
||||||
|
|
||||||
|
User.users.push(user);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineService({
|
||||||
|
Query: {
|
||||||
|
me: User.me,
|
||||||
|
info: () => "Public Data",
|
||||||
|
},
|
||||||
|
Mutation: {
|
||||||
|
createUser: User.create,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const configureApp: PylonAPI["configureApp"] = (app) => {
|
||||||
|
// Initialize the authentication middleware
|
||||||
|
app.use("*", auth.initialize());
|
||||||
|
|
||||||
|
// Automatically try to create a user for each request for demonstration purposes
|
||||||
|
app.use(async (_, next) => {
|
||||||
|
try {
|
||||||
|
await User.create();
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
// Fail silently if the user already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/info", (c) => {
|
||||||
|
return new Response("Public Data");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The `auth.require()` middleware is optional here, as the `User.me` method already checks for it.
|
||||||
|
app.get("/api/me", auth.require(), async (c) => {
|
||||||
|
const user = await User.me();
|
||||||
|
|
||||||
|
return c.json(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A role check for `read:messages` is not required here, as the `user.messages` method already checks for it.
|
||||||
|
app.get("/api/me/messages", auth.require(), async (c) => {
|
||||||
|
const user = await User.me();
|
||||||
|
|
||||||
|
// This will throw an error if the user does not have the `read:messages` role
|
||||||
|
return c.json(await user.messages());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Call the API
|
||||||
|
|
||||||
|
To call the API you need an access token, which is then verified by ZITADEL.
|
||||||
|
Please follow [this guide here](/docs/guides/integrate/token-introspection/private-key-jwt#get-an-access-token), ignoring the first step as we already have the `.json`-key-file from the serviceaccount.
|
||||||
|
|
||||||
|
:::info
|
||||||
|
You can also create a PAT for the serviceuser and use it to test the API. For this, follow [this guide](/docs/guides/integrate/service-users/personal-access-token#create-a-service-user-with-a-pat).
|
||||||
|
:::
|
||||||
|
|
||||||
|
Optionally set the token as an environment variable:
|
||||||
|
|
||||||
|
```
|
||||||
|
export TOKEN='MtjHodGy4zxKylDOhg6kW90WeEQs2q...'
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you have to start the Pylon service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run develop
|
||||||
|
```
|
||||||
|
|
||||||
|
With the access token, you can then do the following calls:
|
||||||
|
|
||||||
|
1. GraphQL:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" -G http://localhost:3000/graphql --data-urlencode 'query={ info }'
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" -G http://localhost:3000/graphql --data-urlencode 'query={ me { id name } }'
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" -G http://localhost:3000/graphql --data-urlencode 'query={ me { id name messages } }'
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also visit the GraphQL playground at `http://localhost:3000/graphql` and execute the queries there.
|
||||||
|
|
||||||
|
2. Routes:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" -X GET http://localhost:3000/api/info
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" -X GET http://localhost:3000/api/me
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" -X GET http://localhost:3000/api/me/messages
|
||||||
|
```
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
Congratulations! You have successfully integrated your Pylon with ZITADEL!
|
||||||
|
|
||||||
|
If you get stuck, consider checking out their [documentation](https://pylon.cronit.io/). If you face issues, contact Pylon or raise an issue on [GitHub](https://github.com/getcronit/pylon/issues).
|
@ -111,5 +111,11 @@
|
|||||||
"imgSrcDark": "/docs/img/tech/rustlight.svg",
|
"imgSrcDark": "/docs/img/tech/rustlight.svg",
|
||||||
"docsLink": "https://github.com/smartive/zitadel-rust",
|
"docsLink": "https://github.com/smartive/zitadel-rust",
|
||||||
"external": true
|
"external": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Pylon",
|
||||||
|
"imgSrcDark": "/docs/img/tech/pylon.svg",
|
||||||
|
"docsLink": "https://github.com/getcronit/pylon",
|
||||||
|
"external": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -38,6 +38,7 @@ module.exports = {
|
|||||||
"examples/secure-api/python-django",
|
"examples/secure-api/python-django",
|
||||||
"examples/secure-api/python-flask",
|
"examples/secure-api/python-flask",
|
||||||
"examples/secure-api/nodejs-nestjs",
|
"examples/secure-api/nodejs-nestjs",
|
||||||
|
"examples/secure-api/pylon",
|
||||||
{
|
{
|
||||||
type: "link",
|
type: "link",
|
||||||
label: ".Net",
|
label: ".Net",
|
||||||
@ -100,6 +101,11 @@ module.exports = {
|
|||||||
label: "Rust",
|
label: "Rust",
|
||||||
href: "https://github.com/smartive/zitadel-rust",
|
href: "https://github.com/smartive/zitadel-rust",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "link",
|
||||||
|
label: "Pylon",
|
||||||
|
href: "https://github.com/getcronit/pylon",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -163,7 +169,7 @@ module.exports = {
|
|||||||
"guides/manage/customize/user-schema",
|
"guides/manage/customize/user-schema",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"guides/manage/terraform-provider"
|
"guides/manage/terraform-provider",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -280,7 +286,7 @@ module.exports = {
|
|||||||
label: "Service Users",
|
label: "Service Users",
|
||||||
link: {
|
link: {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
id: "guides/integrate/service-users/authenticate-service-users"
|
id: "guides/integrate/service-users/authenticate-service-users",
|
||||||
},
|
},
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
items: [
|
items: [
|
||||||
@ -323,7 +329,10 @@ module.exports = {
|
|||||||
{
|
{
|
||||||
type: "category",
|
type: "category",
|
||||||
label: "Login users with SSO",
|
label: "Login users with SSO",
|
||||||
link: { type: "doc", id: "guides/integrate/identity-providers/introduction" },
|
link: {
|
||||||
|
type: "doc",
|
||||||
|
id: "guides/integrate/identity-providers/introduction",
|
||||||
|
},
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
items: [
|
items: [
|
||||||
"guides/integrate/identity-providers/google",
|
"guides/integrate/identity-providers/google",
|
||||||
@ -349,7 +358,7 @@ module.exports = {
|
|||||||
label: "ZITADEL APIs",
|
label: "ZITADEL APIs",
|
||||||
link: {
|
link: {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
id: "guides/integrate/zitadel-apis/access-zitadel-apis"
|
id: "guides/integrate/zitadel-apis/access-zitadel-apis",
|
||||||
},
|
},
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
items: [
|
items: [
|
||||||
@ -478,9 +487,8 @@ module.exports = {
|
|||||||
{
|
{
|
||||||
type: "autogenerated",
|
type: "autogenerated",
|
||||||
dirName: "concepts/structure",
|
dirName: "concepts/structure",
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "category",
|
type: "category",
|
||||||
@ -490,9 +498,8 @@ module.exports = {
|
|||||||
{
|
{
|
||||||
type: "autogenerated",
|
type: "autogenerated",
|
||||||
dirName: "concepts/features",
|
dirName: "concepts/features",
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "autogenerated",
|
type: "autogenerated",
|
||||||
@ -807,10 +814,7 @@ module.exports = {
|
|||||||
type: "category",
|
type: "category",
|
||||||
label: "Actions V2",
|
label: "Actions V2",
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
items: [
|
items: ["apis/actionsv2/introduction", "apis/actionsv2/execution-local"],
|
||||||
"apis/actionsv2/introduction",
|
|
||||||
"apis/actionsv2/execution-local",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "doc",
|
type: "doc",
|
||||||
@ -883,11 +887,9 @@ module.exports = {
|
|||||||
collapsed: false,
|
collapsed: false,
|
||||||
link: {
|
link: {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
id: "self-hosting/manage/cli/overview"
|
id: "self-hosting/manage/cli/overview",
|
||||||
},
|
},
|
||||||
items: [
|
items: ["self-hosting/manage/cli/mirror"],
|
||||||
"self-hosting/manage/cli/mirror"
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
1
docs/static/img/tech/pylon.svg
vendored
Normal file
1
docs/static/img/tech/pylon.svg
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" zoomAndPan="magnify" viewBox="0 0 375 374.999991" height="500" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="0b6ffeb5c8"><path d="M 187.5 0 C 83.945312 0 0 83.945312 0 187.5 C 0 291.054688 83.945312 375 187.5 375 C 291.054688 375 375 291.054688 375 187.5 C 375 83.945312 291.054688 0 187.5 0 Z M 187.5 0 " clip-rule="nonzero"/></clipPath><clipPath id="3768169a3c"><path d="M 137 94.300781 L 238 94.300781 L 238 120 L 137 120 Z M 137 94.300781 " clip-rule="nonzero"/></clipPath><clipPath id="e990912f0b"><path d="M 86.097656 275 L 289 275 L 289 297.503906 L 86.097656 297.503906 Z M 86.097656 275 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#0b6ffeb5c8)"><rect x="-37.5" width="450" fill="#000000" y="-37.499999" height="449.999989" fill-opacity="1"/></g><path fill="#ffffff" d="M 267.359375 242.78125 L 107.640625 242.78125 L 101.558594 268.128906 L 273.4375 268.128906 Z M 267.359375 242.78125 " fill-opacity="1" fill-rule="nonzero"/><g clip-path="url(#3768169a3c)"><path fill="#ffffff" d="M 223.167969 94.300781 L 151.828125 94.300781 C 151.21875 94.304688 150.617188 94.355469 150.019531 94.457031 C 149.421875 94.558594 148.835938 94.707031 148.261719 94.910156 C 147.6875 95.109375 147.136719 95.355469 146.605469 95.644531 C 146.074219 95.9375 145.570312 96.273438 145.09375 96.648438 C 144.617188 97.023438 144.175781 97.4375 143.769531 97.886719 C 143.363281 98.339844 142.996094 98.820312 142.667969 99.332031 C 142.34375 99.84375 142.058594 100.378906 141.824219 100.9375 C 141.585938 101.496094 141.398438 102.070312 141.257812 102.660156 L 137.179688 119.652344 L 237.835938 119.652344 L 233.734375 102.632812 C 233.59375 102.042969 233.402344 101.46875 233.167969 100.914062 C 232.929688 100.355469 232.644531 99.820312 232.320312 99.3125 C 231.992188 98.800781 231.625 98.320312 231.21875 97.875 C 230.8125 97.425781 230.371094 97.011719 229.894531 96.636719 C 229.417969 96.261719 228.914062 95.929688 228.382812 95.640625 C 227.851562 95.347656 227.300781 95.101562 226.726562 94.90625 C 226.15625 94.707031 225.570312 94.554688 224.972656 94.453125 C 224.375 94.351562 223.773438 94.300781 223.167969 94.300781 Z M 223.167969 94.300781 " fill-opacity="1" fill-rule="nonzero"/></g><g clip-path="url(#e990912f0b)"><path fill="#ffffff" d="M 89.71875 275.371094 L 285.277344 275.371094 C 285.757812 275.371094 286.21875 275.464844 286.660156 275.648438 C 287.105469 275.832031 287.496094 276.09375 287.835938 276.433594 C 288.175781 276.773438 288.4375 277.164062 288.621094 277.609375 C 288.804688 278.050781 288.898438 278.511719 288.898438 278.992188 L 288.898438 293.480469 C 288.898438 293.960938 288.804688 294.421875 288.621094 294.867188 C 288.4375 295.308594 288.175781 295.699219 287.835938 296.039062 C 287.496094 296.378906 287.105469 296.640625 286.660156 296.824219 C 286.21875 297.007812 285.757812 297.101562 285.277344 297.101562 L 89.71875 297.101562 C 89.238281 297.101562 88.777344 297.007812 88.335938 296.824219 C 87.890625 296.640625 87.5 296.378906 87.160156 296.039062 C 86.820312 295.699219 86.558594 295.308594 86.375 294.867188 C 86.191406 294.421875 86.097656 293.960938 86.097656 293.480469 L 86.097656 278.992188 C 86.097656 278.511719 86.191406 278.050781 86.375 277.609375 C 86.558594 277.164062 86.820312 276.773438 87.160156 276.433594 C 87.5 276.09375 87.890625 275.832031 88.335938 275.648438 C 88.777344 275.464844 89.238281 275.371094 89.71875 275.371094 Z M 89.71875 275.371094 " fill-opacity="1" fill-rule="nonzero"/></g><path fill="#ffffff" d="M 128.5 155.867188 L 246.5 155.867188 L 239.546875 126.894531 L 135.453125 126.894531 Z M 128.5 155.867188 " fill-opacity="1" fill-rule="nonzero"/><path fill="#ffffff" d="M 109.378906 235.535156 L 265.617188 235.535156 L 256.925781 199.324219 L 118.070312 199.324219 Z M 109.378906 235.535156 " fill-opacity="1" fill-rule="nonzero"/><path fill="#ffffff" d="M 126.753906 163.109375 L 119.804688 192.078125 L 255.191406 192.078125 L 248.242188 163.109375 Z M 126.753906 163.109375 " fill-opacity="1" fill-rule="nonzero"/></svg>
|
After Width: | Height: | Size: 4.1 KiB |
Loading…
Reference in New Issue
Block a user