Bootstraping Strapi Role Permissions - javascript

I am a developer that is doing front end work, strapi and javascript for the first time. I hope someone could take pity on me and provide an example of how to set the Public role permissions via a bootstrap.js script.
node.js v10.16.0
Strapi v3.0.0-next.11
Graphql 14.3.1
MongoDB: 3.6
All on Windows 10
In the Strapi UI, it is the Roles and Permissions for the Public Role
(source: strapi.io)
I want to set these boxes to CHECKED
(source: strapi.io)
Another developer has used the bootstrap.js file to add items to the services we created (menu). I don't know how to return even the most basic information on the role permissions.
My function is called test() I searched for examples and the best I found was this on stackoverflow:
Strapi Plugin Route Default Permission :
strapi.plugins['users-permissions'].models.role.find
but I cannot figure out how to use it:
WORKING
function add_widgets_from_sheet(sheet_name, model_object){
console.log(`adding ${sheet_name}`)
let xlsxSheet = Sheets[sheet_name]
const widgets = XLSX.utils.sheet_to_json(xlsxSheet)
widgets.forEach(function (widget) {
//See if the object is already in the db before adding it
model_object.count(widget)
.then(result => {
if (result == 0) {
console.log('Adding '+sheet_name+': ' + JSON.stringify(widget))
return model_object.add(widget)
}
})
})
}
NOT WORKING
function test(){
console.log(`Testing ${strapi.plugins['users-permissions'].models.role.find}`)
}
module.exports = next => {
console.log('Starting Strapi bootstrap')
add_widgets_from_sheet('Menus', strapi.services.menu) //adding menus
test() // Returning nothing
console.log('Ending Strapi bootstrap')
next()
}
I would like to toggle those checkboxes to TRUE, CHECKED or whatever its called. so that we don't have to manually do it through the UI everytime we dump the database.
I learn best from examples...I hope you can help. Thank you!

So the code below is from a file called PROJECT/STRAPI/config/functions/bootstrap.js
This automates creating the content types and content with information we keep in an excel spreadsheet. But in order to use those content types, there are roles and permissions that have to be activated so the web ui can access them.
Basically, we do not want to manually go into the Strapi UI to create your user, create content types, create content or update the permissions. We want a script to do all of that.
'use strict'
Our Environment Variables
require('dotenv').config({ path:'../.env' })
Excel Spreadsheet Holding our data (attached)
const XLSX = require('xlsx')
const BOOTSTRAP_DATA = XLSX.readFile(process.env.BOOTSTRAP_DATA).Sheets
Variables pulled from .env
const ADMIN_USERNAME = process.env.ADMIN_USERNAME
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
const ADMIN_EMAIL = process.env.ADMIN_EMAIL
Reading in the XLSX
async function bootstrap_resource(resource_type, resource_service) {
strapi.log.info(`Bootstrapping ${resource_type}`)
const resources = XLSX.utils.sheet_to_json(BOOTSTRAP_DATA[resource_type])
for (let resource of resources) {
if (await resource_service.count(resource) === 0) {
strapi.log.warn(`Bootstrapping ${resource_type}: ${JSON.stringify(resource)}`)
await resource_service.create(resource)
}
}
}
Creating the initial USER for strapi
async function bootstrap_admin() {
strapi.log.info(`Bootstrapping Admin`)
const admin_orm = strapi.admin.queries('administrator', 'admin')
const admins = await admin_orm.find({username: ADMIN_USERNAME})
if ( admins.length === 0) {
const blocked = false
const username = ADMIN_USERNAME
const password = await strapi.admin.services.auth.hashPassword(ADMIN_PASSWORD)
const email = ADMIN_EMAIL
const user = { blocked, username, password, email }
const data = await admin_orm.create(user)
strapi.log.warn(`Bootstrapped Admin User: ${JSON.stringify(user)}`)
}
}
The following are get_roles() - required for get_permissions(), and get_permissions() is required for enable_permissions() This is where we turn on those content types so the web ui can see it.
async function get_roles() {
const role_orm = strapi.plugins['users-permissions'].queries('role', 'users-permissions')
const role_list = await role_orm.find({}, [])
const roles = {}
for (let role of role_list) {
roles[ role._id ] = role
roles[ role.name ] = role
}
return roles
}
async function get_permissions( selected_role, selected_type, selected_controller ) {
const roles = await get_roles()
const permission_orm = strapi.plugins['users-permissions'].queries('permission', 'users-permissions')
let permission_list = await permission_orm.find({_limit: 999}, [])
if ( selected_role ) permission_list = permission_list.filter( ({ role }) => `${role}` === `${roles[selected_role]._id}` )
if ( selected_type ) permission_list = permission_list.filter( ({ type }) => `${type}` === `${selected_type}` )
if ( selected_controller ) permission_list = permission_list.filter( ({ controller }) => `${controller}` === `${selected_controller}` )
return permission_list
}
async function enable_permissions(role, type, controller) {
strapi.log.info(`Setting '${controller}' permissions for '${role}'`)
const permission_orm = strapi.plugins['users-permissions'].queries('permission', 'users-permissions')
const permissions = await get_permissions(role, type, controller)
for (let { _id } of permissions) {
permission_orm.update({ _id }, { enabled: true })
}
}
Finally, we run the program
module.exports = async next => {
await bootstrap_admin()
await bootstrap_resource( 'Clients', strapi.services.client )
await bootstrap_resource( 'Menus', strapi.services.menu )
enable_permissions('Public', 'application', 'client' )
enable_permissions('Public', 'application', 'github' )
enable_permissions('Public', 'application', 'menu' )
enable_permissions('Public', 'application', 'confluence' )
next()
}
Take out my comments and you have the entire bootstrap.js file. The images below show the 3 tabs of the demo.xlsx workbook that is used to populate everything.
Finally, showing the results. Menus (content), permissions set and the public website using Nuxt.

Building on both of the previous answers, it seems you can get away with a single loop and in that you can set permissions for both public and authenticated users.
This was written against strapi 3.2.4 and I'm using NodeJS 12 so things like the spread operator ... are available.
const permOrm = strapi.query('permission', 'users-permissions')
const perms = await permOrm.find({ type: 'application' })
for (const curr of perms) {
if (curr.role.type === 'authenticated') {
strapi.log.info(
`Allowing authenticated to call ${curr.controller}.${curr.action}`,
)
permOrm.update({ id: curr.id }, { ...curr, enabled: true })
continue
}
// permission is for public
const isReadEndpoint = ['find', 'findone', 'count'].includes(curr.action)
if (isReadEndpoint) {
strapi.log.info(
`Allowing public to call ${curr.controller}.${curr.action}`,
)
permOrm.update({ id: curr.id }, { ...curr, enabled: true })
continue
}
// TODO add custom logic for any non-standard actions here
strapi.log.info(
`Disallowing public from calling ${curr.controller}.${curr.action}`,
)
permOrm.update({ id: curr.id }, { ...curr, enabled: false })
}

I find this simpler.
// In your bootstrap.js file
'use strict';
module.exports = async () => {
const authenticated = await strapi.query('role', 'users-permissions').findOne({ type: 'authenticated' });
authenticated.permissions.forEach(permission => {
if (permission.type === 'application'){ // Whatever permissions you want to change
let newPermission = permission;
newPermission.enabled = true; // Editing permission as needed
strapi.query('permission', 'users-permissions').update( { id: newPermission.id }, newPermission ); // Updating Strapi with the permission
}
});
return;
};

For strapi 3.3.x and probably future versions
Slightly efficient way (queries role one time, uses query to filter permissions)
'use strict';
module.exports = async () => {
const publicRole = await getRoleByName('Public')
await grantPermissions(publicRole, 'application', 'images', ['upload', 'remove']) // upload, remove in 'images' controller
await grantPermissions(publicRole, 'application', 'project') // any action in 'project' controller
};
async function getRoleByName(name) {
return strapi.query('role', 'users-permissions').findOne({ name }, [])
}
async function getPermissions(role, permissionType, controller, actions = null) {
const permissionQuery = strapi.query('permission', 'users-permissions')
const permissionRequest = {
_limit: 1000,
role: role.id,
type: permissionType,
controller: controller
}
if (actions) {
permissionRequest.action_in = Array.isArray(actions) ? actions : [actions]
}
return permissionQuery.find(permissionRequest, [])
}
async function grantPermissions(role, permissionType, controller, actions) {
if (actions && !Array.isArray(actions)) {
actions = [ actions ]
}
strapi.log.info(`Setting '${controller}' [${actions ? actions.join(', ') : '*'}] permissions for '${role.name}'`)
const permissionQuery = strapi.query('permission', 'users-permissions')
const permissions = await getPermissions(role, permissionType, controller, actions)
if (permissions.length === 0) {
throw new Error(`Error enabling permissions: ${role.name}, ${permissionType}, ${controller}, ${actions}`)
}
for (const { id } of permissions) {
await permissionQuery.update({ id }, { enabled: true })
}
}

Related

How to update RTK Query cache when Firebase RTDB change event fired (update, write, create, delete)

I am using redux-tookit, rtk-query (for querying other api's and not just Firebase) and Firebase (for authentication and db).
The code below works just fine for retrieving and caching the data but I wish to take advantage of both rtk-query caching as well as Firebase event subscribing, so that when ever a change is made in the DB (from any source even directly in firebase console) the cache is updated.
I have tried both updateQueryCache and invalidateTags but so far I am not able to find an ideal approach that works.
Any assistance in pointing me in the right direction would be greatly appreciated.
// firebase.ts
export const onRead = (
collection: string,
callback: (snapshort: DataSnapshot) => void,
options: ListenOptions = { onlyOnce: false }
) => onValue(ref(db, collection), callback, options);
export async function getCollection<T>(
collection: string,
onlyOnce: boolean = false
): Promise<T> {
let timeout: NodeJS.Timeout;
return new Promise<T>((resolve, reject) => {
timeout = setTimeout(() => reject('Request timed out!'), ASYNC_TIMEOUT);
onRead(collection, (snapshot) => resolve(snapshot.val()), { onlyOnce });
}).finally(() => clearTimeout(timeout));
}
// awards.ts
const awards = dbApi
.enhanceEndpoints({ addTagTypes: ['Themes'] })
.injectEndpoints({
endpoints: (builder) => ({
getThemes: builder.query<ThemeData[], void>({
async queryFn(arg, api) {
try {
const { auth } = api.getState() as RootState;
const programme = auth.user?.unit.guidingProgramme!;
const path = `/themes/${programme}`;
const themes = await getCollection<ThemeData[]>(path, true);
return { data: themes };
} catch (error) {
return { error: error as FirebaseError };
}
},
providesTags: ['Themes'],
keepUnusedDataFor: 1000 * 60
}),
getTheme: builder.query<ThemeData, string | undefined>({
async queryFn(slug, api) {
try {
const initiate = awards.endpoints.getThemes.initiate;
const getThemes = api.dispatch(initiate());
const { data } = (await getThemes) as ApiResponse<ThemeData[]>;
const name = slug
?.split('-')
.map(
(value) =>
value.substring(0, 1).toUpperCase() +
value.substring(1).toLowerCase()
)
.join(' ');
return { data: data?.find((theme) => theme.name === name) };
} catch (error) {
return { error: error as FirebaseError };
}
},
keepUnusedDataFor: 0
})
})
});

pg-promise duplicate connection warning on console when set new column set

I'm new to nextjs and I'm creating API on next.js to perform db update using the pg-promise. However, it always hit the WARNING: Creating a duplicate database object for the same connection on console when the app is calling the API.
I tried browsing the docs but couldn't find a solution. I also tried solution (update-2) mentioned on stackoverflow page below, but the warning still exists.
Where should I initialize pg-promise
I think the problem is on the method I used to set the columnset. However I can't find proper way to do it. How should I fix it with pg-promise ?
Db setting code:
import ConfigEnv from 'utils/configuration';
import * as pgLib from 'pg-promise';
const initOptions = {
capSQL: true,
};
const pgp = require('pg-promise')(initOptions);
interface IDatabaseScope {
db: pgLib.IDatabase<any>;
pgp: pgLib.IMain;
}
export function createSingleton<T>(name: string, create: () => T): T {
const s = Symbol.for(name);
let scope = (global as any)[s];
if (!scope) {
scope = {...create()};
(global as any)[s] = scope;
}
return scope;
}
export function getDB(): IDatabaseScope {
return createSingleton<IDatabaseScope>('my-app-db-space', () => {
return {
db: pgp(ConfigEnv.pgp),
pgp
};
});
}
API code:
import {getDB} from 'db/pgpdb';
const {db, pgp} = getDB();
const cs = new pgp.helpers.ColumnSet([
'?detail_id',
'age',
'name'
// 'last_modified_date',
], {
table: 'user_detail',
})
export default async (req, res) => {
try {
// generating the update query where it is needed:
const update = pgp.helpers.update(req.body.content, cs) + ` WHERE v.detail_id = t.detail_id`;
// executing the query
await db
.none(update)
.then(() => {
return res.status(200).end();
})
.catch((error) => {
console.log('error', error);
return res.status(500).send(error);
});
} catch (error) {
console.log(error);
}
};

How can I generate a separate NextJS page for each FaunaDB Document?

How can I generate a different title on every page within a sub-directory?
My code throws no errors, but unfortunately the Title component renders every title-item on every page that it is supposed to, e.g. every app.com/title/<title> renders the same view ( a list of titles). I think that getStaticPaths is correctly parameterised, but I don't think that getStaticProps is.
export default function Title({ paper }) {
// paper is just the entire dataset, and isn't split by id or author etc.
return (
<div>
{paper.map(paper => (
<h1>{paper.data.title}</h1>
))}
</div>
)
}
export async function getStaticProps({ params }) {
// ideally, results should be split down to e.g. `/api/getPapers/title`, but this didn't work
const results = await fetch(`http://localhost:3000/api/getPapers/`).then(res => res.json());
return {
props: {
paper: results
}
}
}
export async function getStaticPaths() {
const papers = await fetch('http://localhost:3000/api/getPapers').then(res => res.json());
const paths = papers.map(paper => {
return {
params: {
authors: paper.data.title.toLowerCase().replace(/ /g, '-')
}
}
})
return {
paths,
fallback: false
}
}
This is the getPapers API function.
const faunadb = require("faunadb");
// your secret hash
const secret = process.env.FAUNADB_SECRET_KEY;
const q = faunadb.query;
const client = new faunadb.Client({ secret });
module.exports = async (req, res) => {
try {
const dbs = await client.query(
q.Map(
// iterate each item in result
q.Paginate(
// make paginatable
q.Match(
// query index
q.Index("all_research_papers") // specify source
)
),
(ref) => q.Get(ref) // lookup each result by its reference
)
);
// ok
res.status(200).json(dbs.data);
} catch (e) {
// something went wrong
res.status(500).json({ error: e.message });
}
};
My attempts to render a separate page for each document were missing a dynamic API call. I was simply hoping to render dynamic pages with a single (batched-document) API call.
Here is a typical dynamic API route called [index.js]:
const faunadb = require('faunadb')
// your secret hash
const secret = process.env.FAUNADB_SECRET_KEY
const q = faunadb.query
const client = new faunadb.Client({ secret })
export default async (req, res) => {
const {
query: { index },
} = req;
try {
const papers = await client.query(
q.Get(q.Ref(q.Collection('<name of the collection>'), index))
);
res.status(200).json(papers.data);
} catch (e) {
res.status(500).json({ error: e.message });
}
};
Once your data is being retrieved dynamically, you can set up a dynamic page route, e.g. [id].js, that fetches the data using useSWR.
const { data, error } = useSWR(`/api/getPapers/${id}`, fetcher);
This is an example fetcher function:
const fetcher = (url) => fetch(url).then((r) => r.json());
In my case, I could then retrieve any given field using the call {data.<field>}.
You are returning authors in your Path object. You will need to make sure that your page file is named with authors included. For example:
app_directory
|- pages
|- home.js
|- title
|- [authors].js
Perhaps where you say authors in your params object, you do mean title. In which case, rename the params object and page filename.
const paths = papers.map(paper => {
return {
params: {
title: paper.data.title.toLowerCase().replace(/ /g, '-')
}
}
})
app_directory
|- pages
|- home.js
|- title
|- [title].js
Here are the docs for getStaticPaths(). https://nextjs.org/docs/basic-features/data-fetching#getstaticpaths-static-generation
EDIT:
Since your API function returns the Page from your query, the shape of the result will likely be
{
before: [/* before cursor */],
after: [/* after cursor */],
data: [
{ /* paper Document */ },
{ /* paper Document */ },
{ /* paper Document */ },
]
}
In which case, your code will need to map over papers.data not on papers itself.
const paths = papers.data // select the data
.map(paper => {
return {
params: {
title: paper.data.title.toLowerCase().replace(/ /g, '-')
}
}
})

Can I make a variable universally available to each page in a view controller file?

I have a viewController file in my application where I will pass variables into the front end of my application. However, I have certain variables that need to go in each page. Instead of repeating my code over and over, is there a way to pass a variable into each page?
In the example below, I define cruiseLines for exports.getIndex. But I have the same exact code in exports.aboutUs and exports.shipDetails, and so on and so on. Any help would be appreciated. Thank you!
exports.getIndex = async (req, res, next) => {
// 1) Get tour data from Collection
const ships = await Ship.find().sort({ shipName: 1 });
const cruiseLines = await Ship.distinct('cruiseLine');
const reviewCount = await Review.countDocuments();
// 2) Build template
// 3) Render the template from the tour data from step 1
res.status(200).render('main', {
title: 'Welcome',
ships,
reviewCount,
cruiseLines,
});
};
You can create a factory service and use that in your code. Something like this.
// ShipSevice.js
const getData = async () => { // you can rename the method
try {
const ships = await Ship.find().sort({ shipName: 1 });
const cruiseLines = await Ship.distinct('cruiseLine');
const reviewCount = await Review.countDocuments();
return { ships, cruiseLines, reviewCount }
} catch(err) {
return {}
}
}
module.exports = { getData }
const { getData } = require('relative-path/ShipSevice');
exports.getIndex = async (req, res, next) => {
try {
// 1) Get tour data from Collection
const { ships, cruiseLines, reviewCount } = await getData()
// 2) Build template
// 3) Render the template from the tour data from step 1
res.status(200).render('main', {
title: 'Welcome',
ships,
reviewCount,
cruiseLines,
});
} catch(err) {
res.status(400).json({ message: 'failed to fetch the'})
}
};

Firebase Permission denied Error on Firebase emulator

I am referencing this tutorial for Firestore security rules. I have extracted the code from the repository and it matches that of the video.
I changed the setup code to run the firestore.rules instead of firestore-test.rules, and tried running firebase emulators:start and jest ./spec following the same directory structure, I fail the tests of "should allow delete when user is admin" and "should not allow delete for normal user" and the reason it is failing is due to the write rule in the wildcard. Does anyone know what is wrong?
collections.spec.js
const { setup, teardown } = require("./helpers");
describe("General Safety Rules", () => {
afterEach(async () => {
await teardown();
});
test("should deny a read to the posts collection", async () => {
const db = await setup();
const postsRef = db.collection("posts");
await expect(postsRef.get()).toDeny();
});
test("should deny a write to users even when logged in", async () => {
const db = await setup({
uid: "danefilled"
});
const usersRef = db.collection("users");
await expect(usersRef.add({ data: "something" })).toDeny();
});
});
describe("Posts Rules", () => {
afterEach(async () => {
await teardown();
});
test("should allow update when user owns post", async () => {
const mockData = {
"posts/id1": {
userId: "danefilled"
},
"posts/id2": {
userId: "not_filledstacks"
}
};
const mockUser = {
uid: "danefilled"
};
const db = await setup(mockUser, mockData);
const postsRef = db.collection("posts");
await expect(
postsRef.doc("id1").update({ updated: "new_value" })
).toAllow();
await expect(postsRef.doc("id2").update({ updated: "new_value" })).toDeny();
});
test("should allow delete when user owns post", async () => {
const mockData = {
"posts/id1": {
userId: "danefilled"
},
"posts/id2": {
userId: "not_filledstacks"
}
};
const mockUser = {
uid: "danefilled"
};
const db = await setup(mockUser, mockData);
const postsRef = db.collection("posts");
await expect(postsRef.doc("id1").delete()).toAllow();
await expect(postsRef.doc("id2").delete()).toDeny();
});
test("should allow delete when user is admin", async () => {
const mockData = {
"users/filledstacks": {
userRole: "Admin"
},
"posts/id1": {
userId: "not_matching1"
},
"posts/id2": {
userId: "not_matching2"
}
};
const mockUser = {
uid: "filledstacks"
};
const db = await setup(mockUser, mockData);
const postsRef = db.collection("posts");
await expect(postsRef.doc("id1").delete()).toAllow();
});
test("should not allow delete for normal user", async () => {
const mockData = {
"users/filledstacks": {
userRole: "User"
},
"posts/id1": {
userId: "not_matching1"
},
"posts/id2": {
userId: "not_matching2"
}
};
const mockUser = {
uid: "filledstacks"
};
const db = await setup(mockUser, mockData);
const postsRef = db.collection("posts");
await expect(postsRef.doc("id1").delete()).toDeny();
});
test("should allow adding a post when logged in", async () => {
const db = await setup({
uid: "userId"
});
const postsRef = db.collection("posts");
await expect(postsRef.add({ title: "new_post" })).toAllow();
});
test("should deny adding a post when not logged in", async () => {
const db = await setup();
const postsRef = db.collection("posts");
await expect(postsRef.add({ title: "new post" })).toDeny();
});
});
firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// lock down the db
match /{document=**} {
allow read: if false;
allow write: if false;
}
match /posts/{postId} {
allow update: if userOwnsPost();
allow delete: if userOwnsPost() || userIsAdmin();
allow create: if loggedIn();
}
function loggedIn() {
return request.auth.uid != null;
}
function userIsAdmin() {
return getUserData().userRole == 'Admin';
}
function getUserData() {
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data
}
function userOwnsPost() {
return resource.data.userId == request.auth.uid;
}
}
}
Error trace from terminal
FirebaseError: 7 PERMISSION_DENIED:
false for 'create' # L10
● Posts Rules › should not allow delete for normal user
FirebaseError: 7 PERMISSION_DENIED:
false for 'create' # L10
at new FirestoreError (/Users/../../../../../../../../../Resources/rules/node_modules/#firebase/firestore/src/util/error.ts:166:5)
at ClientDuplexStream.<anonymous> (/Users/../../../../../../../../../Resources/rules/node_modules/#firebase/firestore/src/platform_node/grpc_connection.ts:240:13)
at ClientDuplexStream._emitStatusIfDone (/Users/../../../../../../../../../Resources/rules/node_modules/grpc/src/client.js:234:12)
at ClientDuplexStream._receiveStatus (/Users/../../../../../../../../../Resources/rules/node_modules/grpc/src/client.js:211:8)
at Object.onReceiveStatus (/Users/../../../../../../../../../Resources/rules/node_modules/grpc/src/client_interceptors.js:1311:15)
at InterceptingListener._callNext (/Users/../../../../../../../../../Resources/rules/node_modules/grpc/src/client_interceptors.js:568:42)
at InterceptingListener.onReceiveStatus (/Users/../../../../../../../../../Resources/rules/node_modules/grpc/src/client_interceptors.js:618:8)
at /Users/../../../../../../../../../Resources/rules/node_modules/grpc/src/client_interceptors.js:1127:18
I actually followed the same tutorial to get started with the firebase emulator and got the same kind of error messages. The problem for me was that when you start the simulator it automatically looks for your firestore.rules file and loads the rules. So, when you then add your mockData the rules already apply.
In order to make your test code work either change the setting for your firestore rules file in your firebase.json to a non-existing file (or rules file that allows all read/write) or add the mockData as an admin in your setup function, e.g.:
module.exports.setup = async (auth, data) => {
const projectId = `rules-spec-${Date.now()}`;
const app = firebase.initializeTestApp({
projectId,
auth
});
const db = app.firestore();
// Initialize admin app
const adminApp = firebase.initializeAdminApp({
projectId
});
const adminDB = adminApp.firestore();
// Write mock documents before rules using adminApp
if (data) {
for (const key in data) {
const ref = adminDB.doc(key);
await ref.set(data[key]);
}
}
// Apply rules
await firebase.loadFirestoreRules({
projectId,
rules: fs.readFileSync('firestore.rules', 'utf8')
});
return db;
};
Hope this helps.
Also see this question
For those that are currently having this issue firestore 8.6.1 (or equivalent), there is a bug discussed here:
https://github.com/firebase/firebase-tools/issues/3258#issuecomment-814402977
The fix is to downgrade to firestore 8.3.1, or if you are reading this in the future and firestore >= 9.9.0 has been released, upgrade to that version.

Categories