From c7df11af0b50975897759c0b467764c66a6ed340 Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Mon, 20 Apr 2020 00:18:40 +0200 Subject: [PATCH] Add support for attachment (system, settings) --- api/system/spec.json | 148 ++++ api/system/spec/attachment.json | 121 +++ api/system/spec/settings.json | 28 + codegen.sh | 2 + docs/system/README.md | 102 +++ system/db/mysql/static.go | 2 +- .../mysql/20200419125927.attachment.up.sql | 22 + system/repository/attachment.go | 139 ++++ system/rest/attachment.go | 189 +++++ system/rest/handlers/attachment.go | 139 ++++ system/rest/handlers/settings.go | 23 + system/rest/request/attachment.go | 686 ++++++++++++++++++ system/rest/request/settings.go | 124 ++++ system/rest/router.go | 1 + system/rest/settings.go | 36 +- system/service/attachment.go | 308 ++++++++ system/service/service.go | 42 ++ system/types/attachment.gen.go | 67 ++ system/types/attachment.gen_test.go | 102 +++ system/types/attachment.go | 104 +++ 20 files changed, 2383 insertions(+), 2 deletions(-) create mode 100644 api/system/spec/attachment.json create mode 100644 system/db/schema/mysql/20200419125927.attachment.up.sql create mode 100644 system/repository/attachment.go create mode 100644 system/rest/attachment.go create mode 100644 system/rest/handlers/attachment.go create mode 100644 system/rest/request/attachment.go create mode 100644 system/service/attachment.go create mode 100644 system/types/attachment.gen.go create mode 100644 system/types/attachment.gen_test.go create mode 100644 system/types/attachment.go diff --git a/api/system/spec.json b/api/system/spec.json index 72618b5d5..ec5f5d24d 100644 --- a/api/system/spec.json +++ b/api/system/spec.json @@ -271,6 +271,34 @@ }] } }, + { + "name": "set", + "path": "/{key}", + "method": "POST", + "title": "Set value for specific setting", + "parameters": { + "path": [ + { + "name": "key", + "type": "string", + "title": "Key", + "required": true + } + ], + "post": [ + { + "name": "upload", + "type": "*multipart.FileHeader", + "title": "File to upload" + }, + { + "name": "ownerID", + "type": "uint64", + "title": "Owner ID" + } + ] + } + }, { "name": "current", "method": "GET", @@ -1612,6 +1640,126 @@ } ] }, + { + "title": "Attachments", + "path": "/attachment/{kind}", + "entrypoint": "attachment", + "authentication": [ + "Client ID", + "Session ID" + ], + "parameters": { + "path": [ + { + "name": "kind", + "type": "string", + "required": true, + "title": "Kind" + } + ], + "get": [ + { + "type": "string", + "name": "sign", + "required": false, + "title": "Signature" + }, + { + "type": "uint64", + "name": "userID", + "required": false, + "title": "User ID" + } + ] + }, + "apis": [ + { + "name": "read", + "path": "/{attachmentID}", + "method": "GET", + "title": "Attachment details", + "parameters": { + "path": [ + { + "name": "attachmentID", + "type": "uint64", + "required": true, + "title": "Attachment ID" + } + ] + } + }, + { + "name": "delete", + "path": "/{attachmentID}", + "method": "DELETE", + "title": "Delete attachment", + "parameters": { + "path": [ + { + "name": "attachmentID", + "type": "uint64", + "required": true, + "title": "Attachment ID" + } + ] + } + }, + { + "name": "original", + "path": "/{attachmentID}/original/{name}", + "method": "GET", + "title": "Serves attached file", + "parameters": { + "path": [ + { + "name": "attachmentID", + "type": "uint64", + "required": true, + "title": "Attachment ID" + }, + { + "name": "name", + "type": "string", + "required": true, + "title": "File name" + } + ], + "get": [ + { + "type": "bool", + "name": "download", + "required": false, + "title": "Force file download" + } + ] + } + }, + { + "name": "preview", + "path": "/{attachmentID}/preview.{ext}", + "method": "GET", + "title": "Serves preview of an attached file", + "parameters": { + "path": [ + { + "name": "attachmentID", + "type": "uint64", + "required": true, + "title": "Attachment ID" + }, + { + "name": "ext", + "type": "string", + "required": true, + "title": "Preview extension/format" + } + ] + } + } + ] + }, + { "title": "Statistics", "entrypoint": "stats", diff --git a/api/system/spec/attachment.json b/api/system/spec/attachment.json new file mode 100644 index 000000000..213b5cbcb --- /dev/null +++ b/api/system/spec/attachment.json @@ -0,0 +1,121 @@ +{ + "Title": "Attachments", + "Interface": "Attachment", + "Struct": null, + "Parameters": { + "get": [ + { + "name": "sign", + "required": false, + "title": "Signature", + "type": "string" + }, + { + "name": "userID", + "required": false, + "title": "User ID", + "type": "uint64" + } + ], + "path": [ + { + "name": "kind", + "required": true, + "title": "Kind", + "type": "string" + } + ] + }, + "Protocol": "", + "Authentication": [ + "Client ID", + "Session ID" + ], + "Path": "/attachment/{kind}", + "APIs": [ + { + "Name": "read", + "Method": "GET", + "Title": "Attachment details", + "Path": "/{attachmentID}", + "Parameters": { + "path": [ + { + "name": "attachmentID", + "required": true, + "title": "Attachment ID", + "type": "uint64" + } + ] + } + }, + { + "Name": "delete", + "Method": "DELETE", + "Title": "Delete attachment", + "Path": "/{attachmentID}", + "Parameters": { + "path": [ + { + "name": "attachmentID", + "required": true, + "title": "Attachment ID", + "type": "uint64" + } + ] + } + }, + { + "Name": "original", + "Method": "GET", + "Title": "Serves attached file", + "Path": "/{attachmentID}/original/{name}", + "Parameters": { + "get": [ + { + "name": "download", + "required": false, + "title": "Force file download", + "type": "bool" + } + ], + "path": [ + { + "name": "attachmentID", + "required": true, + "title": "Attachment ID", + "type": "uint64" + }, + { + "name": "name", + "required": true, + "title": "File name", + "type": "string" + } + ] + } + }, + { + "Name": "preview", + "Method": "GET", + "Title": "Serves preview of an attached file", + "Path": "/{attachmentID}/preview.{ext}", + "Parameters": { + "path": [ + { + "name": "attachmentID", + "required": true, + "title": "Attachment ID", + "type": "uint64" + }, + { + "name": "ext", + "required": true, + "title": "Preview extension/format", + "type": "string" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/api/system/spec/settings.json b/api/system/spec/settings.json index 7366a0b39..8a47424f9 100644 --- a/api/system/spec/settings.json +++ b/api/system/spec/settings.json @@ -67,6 +67,34 @@ ] } }, + { + "Name": "set", + "Method": "POST", + "Title": "Set value for specific setting", + "Path": "/{key}", + "Parameters": { + "path": [ + { + "name": "key", + "required": true, + "title": "Key", + "type": "string" + } + ], + "post": [ + { + "name": "upload", + "title": "File to upload", + "type": "*multipart.FileHeader" + }, + { + "name": "ownerID", + "title": "Owner ID", + "type": "uint64" + } + ] + } + }, { "Name": "current", "Method": "GET", diff --git a/codegen.sh b/codegen.sh index 1579cfafe..d36146856 100755 --- a/codegen.sh +++ b/codegen.sh @@ -82,6 +82,7 @@ function types { ./build/gen-type-set --types Organisation --output system/types/organisation.gen.go ./build/gen-type-set --types Credentials --output system/types/credentials.gen.go ./build/gen-type-set --types Reminder --output system/types/reminder.gen.go + ./build/gen-type-set --types Attachment --output system/types/attachment.gen.go ./build/gen-type-set-test --types User --output system/types/user.gen_test.go ./build/gen-type-set-test --types Application --output system/types/application.gen_test.go @@ -89,6 +90,7 @@ function types { ./build/gen-type-set-test --types Organisation --output system/types/organisation.gen_test.go ./build/gen-type-set-test --types Credentials --output system/types/credentials.gen_test.go ./build/gen-type-set-test --types Reminder --output system/types/reminder.gen_test.go + ./build/gen-type-set-test --types Attachment --output system/types/attachment.gen_test.go ./build/gen-type-set --types Value --output pkg/settings/types.gen.go --with-primary-key=false --package settings ./build/gen-type-set-test --types Value --output pkg/settings/types.gen_test.go --with-primary-key=false --package settings diff --git a/docs/system/README.md b/docs/system/README.md index f42f7aabb..0e1f4eee6 100644 --- a/docs/system/README.md +++ b/docs/system/README.md @@ -128,6 +128,91 @@ +# Attachments + +| Method | Endpoint | Purpose | +| ------ | -------- | ------- | +| `GET` | `/attachment/{kind}/{attachmentID}` | Attachment details | +| `DELETE` | `/attachment/{kind}/{attachmentID}` | Delete attachment | +| `GET` | `/attachment/{kind}/{attachmentID}/original/{name}` | Serves attached file | +| `GET` | `/attachment/{kind}/{attachmentID}/preview.{ext}` | Serves preview of an attached file | + +## Attachment details + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/attachment/{kind}/{attachmentID}` | HTTP/S | GET | Client ID, Session ID | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| attachmentID | uint64 | PATH | Attachment ID | N/A | YES | +| kind | string | PATH | Kind | N/A | YES | +| sign | string | GET | Signature | N/A | NO | +| userID | uint64 | GET | User ID | N/A | NO | + +## Delete attachment + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/attachment/{kind}/{attachmentID}` | HTTP/S | DELETE | Client ID, Session ID | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| attachmentID | uint64 | PATH | Attachment ID | N/A | YES | +| kind | string | PATH | Kind | N/A | YES | +| sign | string | GET | Signature | N/A | NO | +| userID | uint64 | GET | User ID | N/A | NO | + +## Serves attached file + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/attachment/{kind}/{attachmentID}/original/{name}` | HTTP/S | GET | Client ID, Session ID | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| download | bool | GET | Force file download | N/A | NO | +| sign | string | GET | Signature | N/A | NO | +| userID | uint64 | GET | User ID | N/A | NO | +| attachmentID | uint64 | PATH | Attachment ID | N/A | YES | +| name | string | PATH | File name | N/A | YES | +| kind | string | PATH | Kind | N/A | YES | + +## Serves preview of an attached file + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/attachment/{kind}/{attachmentID}/preview.{ext}` | HTTP/S | GET | Client ID, Session ID | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| attachmentID | uint64 | PATH | Attachment ID | N/A | YES | +| ext | string | PATH | Preview extension/format | N/A | YES | +| kind | string | PATH | Kind | N/A | YES | +| sign | string | GET | Signature | N/A | NO | +| userID | uint64 | GET | User ID | N/A | NO | + +--- + + + + # Authentication | Method | Endpoint | Purpose | @@ -953,6 +1038,7 @@ An organisation may have many roles. Roles may have many channels available. Acc | `GET` | `/settings/` | List settings | | `PATCH` | `/settings/` | Update settings | | `GET` | `/settings/{key}` | Get a value for a key | +| `POST` | `/settings/{key}` | Set value for specific setting | | `GET` | `/settings/current` | Current compose settings | ## List settings @@ -998,6 +1084,22 @@ An organisation may have many roles. Roles may have many channels available. Acc | ownerID | uint64 | GET | Owner ID | N/A | NO | | key | string | PATH | Setting key | N/A | YES | +## Set value for specific setting + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/settings/{key}` | HTTP/S | POST | | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| key | string | PATH | Key | N/A | YES | +| upload | *multipart.FileHeader | POST | File to upload | N/A | NO | +| ownerID | uint64 | POST | Owner ID | N/A | NO | + ## Current compose settings #### Method diff --git a/system/db/mysql/static.go b/system/db/mysql/static.go index 48012111e..de456b9f5 100644 --- a/system/db/mysql/static.go +++ b/system/db/mysql/static.go @@ -3,4 +3,4 @@ // Package contains static assets. package mysql -var Asset = "PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1a\x00 \x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8-- all known organisations (crust instances) and our relation towards them\nCREATE TABLE organisations (\n id BIGINT UNSIGNED NOT NULL,\n fqn TEXT NOT NULL, -- fully qualified name of the organisation\n name TEXT NOT NULL, -- display name of the organisation\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- organisation soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE settings (\n name VARCHAR(200) NOT NULL COMMENT 'Unique set of setting keys',\n value TEXT COMMENT 'Setting value',\n\n PRIMARY KEY (name)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps all known users, home and external organisation\n-- changes are stored in audit log\nCREATE TABLE users (\n id BIGINT UNSIGNED NOT NULL,\n email TEXT NOT NULL,\n username TEXT NOT NULL,\n password TEXT NOT NULL,\n name TEXT NOT NULL,\n handle TEXT NOT NULL,\n meta JSON NOT NULL,\n satosa_id CHAR(36) NULL,\n\n rel_organisation BIGINT UNSIGNED NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n suspended_at DATETIME NULL,\n deleted_at DATETIME NULL, -- user soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE UNIQUE INDEX uid_satosa ON users (satosa_id);\n\n-- Keeps all known teams\nCREATE TABLE teams (\n id BIGINT UNSIGNED NOT NULL,\n name TEXT NOT NULL, -- display name of the team\n handle TEXT NOT NULL, -- team handle string\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- team soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps team memberships\nCREATE TABLE team_members (\n rel_team BIGINT UNSIGNED NOT NULL REFERENCES organisation(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n PRIMARY KEY (rel_team, rel_user)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\xedzU\x8am \x00\x00m \x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00.\x00 \x0020181124181811.rename_and_prefix_tables.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE teams RENAME TO sys_team;\nALTER TABLE organisations RENAME TO sys_organisation;\nALTER TABLE team_members RENAME TO sys_team_member;\nALTER TABLE users RENAME TO sys_user;PK\x07\x08\xf2\xc4\x87\xe8\xb5\x00\x00\x00\xb5\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00-\x00 \x0020181125100429.add_user_kind_and_owner.up.sqlUT\x05\x00\x01\x80Cm8# add field to manage user type (bot support)\nALTER TABLE `sys_user` ADD `kind` VARCHAR(8) NOT NULL DEFAULT '' AFTER `handle`;\n\n# add field to manage \"ownership\" (get all bots created by user)\nALTER TABLE `sys_user` ADD `rel_user_id` BIGINT UNSIGNED NOT NULL AFTER `rel_organisation`, ADD INDEX (`rel_user_id`);\nPK\x07\x089\xa0\xdat8\x01\x00\x008\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00-\x00 \x0020181125153544.satosa_index_not_unique.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `sys_user` DROP INDEX `uid_satosa`, ADD INDEX `uid_satosa` (`satosa_id`) USING BTREE;PK\x07\x08\x0d\xf9\xd3ga\x00\x00\x00a\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00!\x00 \x0020181208140000.credentials.up.sqlUT\x05\x00\x01\x80Cm8-- Keeps all known users, home and external organisation\n-- changes are stored in audit log\nCREATE TABLE sys_credentials (\n id BIGINT UNSIGNED NOT NULL,\n rel_owner BIGINT UNSIGNED NOT NULL REFERENCES sys_users(id),\n label TEXT NOT NULL COMMENT 'something we can differentiate credentials by',\n kind VARCHAR(128) NOT NULL COMMENT 'hash, facebook, gplus, github, linkedin ...',\n credentials TEXT NOT NULL COMMENT 'crypted/hashed passwords, secrets, social profile ID',\n meta JSON NOT NULL,\n expires_at DATETIME NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n deleted_at DATETIME NULL, -- user soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE INDEX idx_owner ON sys_credentials (rel_owner);\nPK\x07\x08f\x1f\x08\xd0\x9a\x03\x00\x00\x9a\x03\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00)\x00 \x0020190103203201.users-password-null.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `sys_user` MODIFY `password` TEXT NULL;\nPK\x07\x080V\x13\x0f4\x00\x00\x004\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1b\x00 \x0020190116102104.rules.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE `sys_rules` (\n `rel_team` BIGINT UNSIGNED NOT NULL,\n `resource` VARCHAR(128) NOT NULL,\n `operation` VARCHAR(128) NOT NULL,\n `value` TINYINT(1) NOT NULL,\n\n PRIMARY KEY (`rel_team`, `resource`, `operation`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\x05\x10[\x91\x05\x01\x00\x00\x05\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00)\x00 \x0020190221001051.rename-team-to-role.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE sys_team RENAME TO sys_role;\nALTER TABLE sys_team_member RENAME TO sys_role_member;\n\nALTER TABLE `sys_role_member` CHANGE COLUMN `rel_team` `rel_role` BIGINT UNSIGNED NOT NULL;\nALTER TABLE `sys_rules` CHANGE COLUMN `rel_team` `rel_role` BIGINT UNSIGNED NOT NULL;\nPK\x07\x08s-\x98\xd0\x13\x01\x00\x00\x13\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00,\x00 \x0020190226160000.system_roles_and_rules.up.sqlUT\x05\x00\x01\x80Cm8REPLACE INTO `sys_role` (`id`, `name`, `handle`) VALUES\n (1, 'Everyone', 'everyone'),\n (2, 'Administrators', 'admins');\n\nPK\x07\x08\x06RHi{\x00\x00\x00{\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\"\x00 \x0020190306205033.applications.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE sys_application (\n id BIGINT UNSIGNED NOT NULL,\n rel_owner BIGINT UNSIGNED NOT NULL REFERENCES sys_users(id),\n name TEXT NOT NULL COMMENT 'something we can differentiate application by',\n enabled BOOL NOT NULL,\n\n unify JSON NULL COMMENT 'unify specific settings',\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n deleted_at DATETIME NULL, -- user soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n\nREPLACE INTO `sys_application` (`id`, `name`, `enabled`, `rel_owner`, `unify`) VALUES\n( 1, 'Crust Messaging', true, 0,\n '{\"logo\": \"/applications/crust.jpg\", \"icon\": \"/applications/crust_favicon.png\", \"url\": \"/messaging/\", \"listed\": true}'\n),\n( 2, 'Crust CRM', true, 0,\n '{\"logo\": \"/applications/crust.jpg\", \"icon\": \"/applications/crust_favicon.png\", \"url\": \"/crm/\", \"listed\": true}'\n),\n( 3, 'Crust Admin Area', true, 0,\n '{\"logo\": \"/applications/crust.jpg\", \"icon\": \"/applications/crust_favicon.png\", \"url\": \"/admin/\", \"listed\": true}'\n),\n( 4, 'Corteza Jitsi Bridge', true, 0,\n '{\"logo\": \"/applications/jitsi.png\", \"icon\": \"/applications/jitsi_icon.png\", \"url\": \"/bridge/jitsi/\", \"listed\": true}'\n),\n( 5, 'Google Maps', true, 0,\n '{\"logo\": \"/applications/google_maps.png\", \"icon\": \"/applications/google_maps_icon.png\", \"url\": \"/bridge/google-maps/\", \"listed\": true}'\n);\n\nPK\x07\x08Oi\xd5\xd3\xc6\x05\x00\x00\xc6\x05\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1e\x00 \x0020190326122000.settings.up.sqlUT\x05\x00\x01\x80Cm8DROP TABLE IF EXISTS `settings`;\n\nCREATE TABLE IF NOT EXISTS `sys_settings` (\n rel_owner BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Value owner, 0 for global settings',\n name VARCHAR(200) NOT NULL COMMENT 'Unique set of setting keys',\n value JSON COMMENT 'Setting value',\n\n updated_at DATETIME NOT NULL DEFAULT NOW() COMMENT 'When was the value updated',\n updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Who created/updated the value',\n\n PRIMARY KEY (name, rel_owner)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08`\xcb\x1b\x81t\x02\x00\x00t\x02\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00 \x0020190403113201.users-cleanup.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `sys_user` DROP `password`;\nALTER TABLE `sys_user` DROP `satosa_id`;\nALTER TABLE `sys_credentials` ADD `last_used_at` DATETIME NULL;\nPK\x07\x088\x92\x0fs\x91\x00\x00\x00\x91\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00 \x0020190405090000.internal-auth.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `sys_user` ADD `email_confirmed` BOOLEAN NOT NULL DEFAULT FALSE;\nPK\x07\x08\x8fQs\x8cM\x00\x00\x00M\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00!\x00 \x0020190506090000.compose-app.up.sqlUT\x05\x00\x01\x80Cm8UPDATE `sys_application`\n SET `name` = 'Crust Compose',\n `unify` = '{\"logo\": \"/applications/crust.jpg\", \"icon\": \"/applications/crust_favicon.png\", \"url\": \"/compose/\", \"listed\": true}'\n WHERE id = 2;\nPK\x07\x08\x10\xe9%]\xd0\x00\x00\x00\xd0\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00!\x00 \x0020190506090000.permissions.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS sys_permission_rules (\n rel_role BIGINT UNSIGNED NOT NULL,\n resource VARCHAR(128) NOT NULL,\n operation VARCHAR(128) NOT NULL,\n access TINYINT(1) NOT NULL,\n\n PRIMARY KEY (rel_role, resource, operation)\n) ENGINE=InnoDB;\n\nCREATE TABLE IF NOT EXISTS messaging_permission_rules (\n rel_role BIGINT UNSIGNED NOT NULL,\n resource VARCHAR(128) NOT NULL,\n operation VARCHAR(128) NOT NULL,\n access TINYINT(1) NOT NULL,\n\n PRIMARY KEY (rel_role, resource, operation)\n) ENGINE=InnoDB;\n\nCREATE TABLE IF NOT EXISTS compose_permission_rules (\n rel_role BIGINT UNSIGNED NOT NULL,\n resource VARCHAR(128) NOT NULL,\n operation VARCHAR(128) NOT NULL,\n access TINYINT(1) NOT NULL,\n\n PRIMARY KEY (rel_role, resource, operation)\n) ENGINE=InnoDB;\n\nREPLACE sys_permission_rules\n (rel_role, resource, operation, access)\n SELECT rel_role, resource, operation, `value` - 1 FROM sys_rules WHERE resource LIKE 'system%';\n\nREPLACE compose_permission_rules\n (rel_role, resource, operation, access)\n SELECT rel_role, resource, operation, `value` - 1 FROM sys_rules WHERE resource LIKE 'compose%';\n\nREPLACE messaging_permission_rules\n (rel_role, resource, operation, access)\n SELECT rel_role, resource, operation, `value` - 1 FROM sys_rules WHERE resource LIKE 'messaging%';\n\nDROP TABLE sys_rules;\nPK\x07\x08\x08\xd4\xe0+e\x05\x00\x00e\x05\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00*\x00 \x0020190826085348.migrate-gplus-google.up.sqlUT\x05\x00\x01\x80Cm8/* migrates existing credentials */\nUPDATE sys_credentials SET kind = 'google' WHERE kind = 'gplus';\n\n/* migrates existing settings. */\nUPDATE sys_settings SET name = REPLACE(name, '.gplus.', '.google.') WHERE name LIKE 'auth.external.providers.gplus.%';\nPK\x07\x08<\xac\xedE\xff\x00\x00\x00\xff\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 \x00 \x0020190902080000.automation.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS sys_automation_script (\n `id` BIGINT(20) UNSIGNED NOT NULL,\n `rel_namespace` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'For compatibility only, not used',\n `name` VARCHAR(64) NOT NULL DEFAULT 'unnamed' COMMENT 'The name of the script',\n `source` TEXT NOT NULL COMMENT 'Source code for the script',\n `source_ref` VARCHAR(200) NOT NULL COMMENT 'Where is the script located (if remote)',\n `async` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Do we run this script asynchronously?',\n `rel_runner` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Who is running the script? 0 for invoker',\n `run_in_ua` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Run this script inside user-agent environment',\n `timeout` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Any explicit timeout set for this script (milliseconds)?',\n `critical` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'Is it critical that this script is executed successfully',\n `enabled` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'Is this script enabled?',\n\n `created_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updated_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `updated_at` DATETIME NULL DEFAULT NULL,\n `deleted_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `deleted_at` DATETIME NULL DEFAULT NULL,\n\n PRIMARY KEY (`id`)\n\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE IF NOT EXISTS sys_automation_trigger (\n `id` BIGINT(20) UNSIGNED NOT NULL,\n `rel_script` BIGINT(20) UNSIGNED NOT NULL COMMENT 'Script that is triggered',\n\n `resource` VARCHAR(128) NOT NULL COMMENT 'Resource triggering the event',\n `event` VARCHAR(128) NOT NULL COMMENT 'Event triggered',\n `event_condition`\n TEXT NOT NULL COMMENT 'Trigger condition',\n `enabled` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'Trigger enabled?',\n\n `weight` INT NOT NULL DEFAULT 0,\n\n `created_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updated_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `updated_at` DATETIME NULL DEFAULT NULL,\n `deleted_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `deleted_at` DATETIME NULL DEFAULT NULL,\n\n CONSTRAINT `fk_sys_automation_script` FOREIGN KEY (`rel_script`) REFERENCES `sys_automation_script` (`id`),\n\n PRIMARY KEY (`id`)\n\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\xac\xbb\x1b\x07i\x0b\x00\x00i\x0b\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1f\x00 \x0020190924093443.reminders.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS sys_reminder (\n `id` BIGINT(20) UNSIGNED NOT NULL,\n `resource` VARCHAR(128) NOT NULL COMMENT 'Resource, that this reminder is bound to',\n `payload` JSON NOT NULL COMMENT 'Payload for this reminder',\n `snooze_count` INT NOT NULL DEFAULT 0 COMMENT 'Number of times this reminder was snoozed',\n\n `assigned_to` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Assignee for this reminder',\n `assigned_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'User that assigned this reminder',\n `assigned_at` DATETIME NOT NULL COMMENT 'When the reminder was assigned',\n\n `dismissed_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'User that dismissed this reminder',\n `dismissed_at` DATETIME NULL DEFAULT NULL COMMENT 'Time the reminder was dismissed',\n\n `remind_at` DATETIME NULL DEFAULT NULL COMMENT 'Time the user should be reminded',\n\n `created_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updated_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `updated_at` DATETIME NULL DEFAULT NULL,\n `deleted_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `deleted_at` DATETIME NULL DEFAULT NULL,\n\n PRIMARY KEY (`id`)\n\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\n\x10\"\x05X\x06\x00\x00X\x06\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00&\x00 \x0020191023213030.settings-cleanup.up.sqlUT\x05\x00\x01\x80Cm8UPDATE `sys_settings` SET `name` = 'general.mail.logo' WHERE `rel_owner` = 0 AND `name` = 'system.defaultLogo';\nUPDATE `sys_settings` SET `name` = 'general.mail.header.en' WHERE `rel_owner` = 0 AND `name` = 'system.mail.header.en';\nUPDATE `sys_settings` SET `name` = 'general.mail.footer.en' WHERE `rel_owner` = 0 AND `name` = 'system.mail.footer.en';\nPK\x07\x08\x98\xd0\xdcje\x01\x00\x00e\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00 \x00migrations.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS `migrations` (\n `project` varchar(16) NOT NULL COMMENT 'sam, crm, ...',\n `filename` varchar(255) NOT NULL COMMENT 'yyyymmddHHMMSS.sql',\n `statement_index` int(11) NOT NULL COMMENT 'Statement number from SQL file',\n `status` TEXT NOT NULL COMMENT 'ok or full error message',\n PRIMARY KEY (`project`,`filename`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nPK\x07\x08\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x06\x00 \x00new.shUT\x05\x00\x01\x80Cm8#!/bin/bash\ntouch $(date +%Y%m%d%H%M%S).up.sqlPK\x07\x08s\xd4N*.\x00\x00\x00.\x00\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xedzU\x8am \x00\x00m \x00\x00\x1a\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xf2\xc4\x87\xe8\xb5\x00\x00\x00\xb5\x00\x00\x00.\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xbe \x00\x0020181124181811.rename_and_prefix_tables.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(9\xa0\xdat8\x01\x00\x008\x01\x00\x00-\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xd8\n\x00\x0020181125100429.add_user_kind_and_owner.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x0d\xf9\xd3ga\x00\x00\x00a\x00\x00\x00-\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81t\x0c\x00\x0020181125153544.satosa_index_not_unique.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(f\x1f\x08\xd0\x9a\x03\x00\x00\x9a\x03\x00\x00!\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x819\x0d\x00\x0020181208140000.credentials.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(0V\x13\x0f4\x00\x00\x004\x00\x00\x00)\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81+\x11\x00\x0020190103203201.users-password-null.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x05\x10[\x91\x05\x01\x00\x00\x05\x01\x00\x00\x1b\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xbf\x11\x00\x0020190116102104.rules.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(s-\x98\xd0\x13\x01\x00\x00\x13\x01\x00\x00)\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x16\x13\x00\x0020190221001051.rename-team-to-role.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x06RHi{\x00\x00\x00{\x00\x00\x00,\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x89\x14\x00\x0020190226160000.system_roles_and_rules.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(Oi\xd5\xd3\xc6\x05\x00\x00\xc6\x05\x00\x00\"\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81g\x15\x00\x0020190306205033.applications.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(`\xcb\x1b\x81t\x02\x00\x00t\x02\x00\x00\x1e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x86\x1b\x00\x0020190326122000.settings.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(8\x92\x0fs\x91\x00\x00\x00\x91\x00\x00\x00#\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81O\x1e\x00\x0020190403113201.users-cleanup.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x8fQs\x8cM\x00\x00\x00M\x00\x00\x00#\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81:\x1f\x00\x0020190405090000.internal-auth.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x10\xe9%]\xd0\x00\x00\x00\xd0\x00\x00\x00!\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xe1\x1f\x00\x0020190506090000.compose-app.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x08\xd4\xe0+e\x05\x00\x00e\x05\x00\x00!\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81 !\x00\x0020190506090000.permissions.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(<\xac\xedE\xff\x00\x00\x00\xff\x00\x00\x00*\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xc6&\x00\x0020190826085348.migrate-gplus-google.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xac\xbb\x1b\x07i\x0b\x00\x00i\x0b\x00\x00 \x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81&(\x00\x0020190902080000.automation.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\n\x10\"\x05X\x06\x00\x00X\x06\x00\x00\x1f\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xe63\x00\x0020190924093443.reminders.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x98\xd0\xdcje\x01\x00\x00e\x01\x00\x00&\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x94:\x00\x0020191023213030.settings-cleanup.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00\x0e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81V<\x00\x00migrations.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(s\xd4N*.\x00\x00\x00.\x00\x00\x00\x06\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xed\x81\x13>\x00\x00new.shUT\x05\x00\x01\x80Cm8PK\x05\x06\x00\x00\x00\x00\x15\x00\x15\x00J\x07\x00\x00~>\x00\x00\x00\x00" +var Asset = "PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1a\x00 \x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8-- all known organisations (crust instances) and our relation towards them\nCREATE TABLE organisations (\n id BIGINT UNSIGNED NOT NULL,\n fqn TEXT NOT NULL, -- fully qualified name of the organisation\n name TEXT NOT NULL, -- display name of the organisation\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- organisation soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE settings (\n name VARCHAR(200) NOT NULL COMMENT 'Unique set of setting keys',\n value TEXT COMMENT 'Setting value',\n\n PRIMARY KEY (name)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps all known users, home and external organisation\n-- changes are stored in audit log\nCREATE TABLE users (\n id BIGINT UNSIGNED NOT NULL,\n email TEXT NOT NULL,\n username TEXT NOT NULL,\n password TEXT NOT NULL,\n name TEXT NOT NULL,\n handle TEXT NOT NULL,\n meta JSON NOT NULL,\n satosa_id CHAR(36) NULL,\n\n rel_organisation BIGINT UNSIGNED NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n suspended_at DATETIME NULL,\n deleted_at DATETIME NULL, -- user soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE UNIQUE INDEX uid_satosa ON users (satosa_id);\n\n-- Keeps all known teams\nCREATE TABLE teams (\n id BIGINT UNSIGNED NOT NULL,\n name TEXT NOT NULL, -- display name of the team\n handle TEXT NOT NULL, -- team handle string\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- team soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps team memberships\nCREATE TABLE team_members (\n rel_team BIGINT UNSIGNED NOT NULL REFERENCES organisation(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n PRIMARY KEY (rel_team, rel_user)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\xedzU\x8am \x00\x00m \x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00.\x00 \x0020181124181811.rename_and_prefix_tables.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE teams RENAME TO sys_team;\nALTER TABLE organisations RENAME TO sys_organisation;\nALTER TABLE team_members RENAME TO sys_team_member;\nALTER TABLE users RENAME TO sys_user;PK\x07\x08\xf2\xc4\x87\xe8\xb5\x00\x00\x00\xb5\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00-\x00 \x0020181125100429.add_user_kind_and_owner.up.sqlUT\x05\x00\x01\x80Cm8# add field to manage user type (bot support)\nALTER TABLE `sys_user` ADD `kind` VARCHAR(8) NOT NULL DEFAULT '' AFTER `handle`;\n\n# add field to manage \"ownership\" (get all bots created by user)\nALTER TABLE `sys_user` ADD `rel_user_id` BIGINT UNSIGNED NOT NULL AFTER `rel_organisation`, ADD INDEX (`rel_user_id`);\nPK\x07\x089\xa0\xdat8\x01\x00\x008\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00-\x00 \x0020181125153544.satosa_index_not_unique.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `sys_user` DROP INDEX `uid_satosa`, ADD INDEX `uid_satosa` (`satosa_id`) USING BTREE;PK\x07\x08\x0d\xf9\xd3ga\x00\x00\x00a\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00!\x00 \x0020181208140000.credentials.up.sqlUT\x05\x00\x01\x80Cm8-- Keeps all known users, home and external organisation\n-- changes are stored in audit log\nCREATE TABLE sys_credentials (\n id BIGINT UNSIGNED NOT NULL,\n rel_owner BIGINT UNSIGNED NOT NULL REFERENCES sys_users(id),\n label TEXT NOT NULL COMMENT 'something we can differentiate credentials by',\n kind VARCHAR(128) NOT NULL COMMENT 'hash, facebook, gplus, github, linkedin ...',\n credentials TEXT NOT NULL COMMENT 'crypted/hashed passwords, secrets, social profile ID',\n meta JSON NOT NULL,\n expires_at DATETIME NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n deleted_at DATETIME NULL, -- user soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE INDEX idx_owner ON sys_credentials (rel_owner);\nPK\x07\x08f\x1f\x08\xd0\x9a\x03\x00\x00\x9a\x03\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00)\x00 \x0020190103203201.users-password-null.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `sys_user` MODIFY `password` TEXT NULL;\nPK\x07\x080V\x13\x0f4\x00\x00\x004\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1b\x00 \x0020190116102104.rules.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE `sys_rules` (\n `rel_team` BIGINT UNSIGNED NOT NULL,\n `resource` VARCHAR(128) NOT NULL,\n `operation` VARCHAR(128) NOT NULL,\n `value` TINYINT(1) NOT NULL,\n\n PRIMARY KEY (`rel_team`, `resource`, `operation`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\x05\x10[\x91\x05\x01\x00\x00\x05\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00)\x00 \x0020190221001051.rename-team-to-role.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE sys_team RENAME TO sys_role;\nALTER TABLE sys_team_member RENAME TO sys_role_member;\n\nALTER TABLE `sys_role_member` CHANGE COLUMN `rel_team` `rel_role` BIGINT UNSIGNED NOT NULL;\nALTER TABLE `sys_rules` CHANGE COLUMN `rel_team` `rel_role` BIGINT UNSIGNED NOT NULL;\nPK\x07\x08s-\x98\xd0\x13\x01\x00\x00\x13\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00,\x00 \x0020190226160000.system_roles_and_rules.up.sqlUT\x05\x00\x01\x80Cm8REPLACE INTO `sys_role` (`id`, `name`, `handle`) VALUES\n (1, 'Everyone', 'everyone'),\n (2, 'Administrators', 'admins');\n\nPK\x07\x08\x06RHi{\x00\x00\x00{\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\"\x00 \x0020190306205033.applications.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE sys_application (\n id BIGINT UNSIGNED NOT NULL,\n rel_owner BIGINT UNSIGNED NOT NULL REFERENCES sys_users(id),\n name TEXT NOT NULL COMMENT 'something we can differentiate application by',\n enabled BOOL NOT NULL,\n\n unify JSON NULL COMMENT 'unify specific settings',\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n deleted_at DATETIME NULL, -- user soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n\nREPLACE INTO `sys_application` (`id`, `name`, `enabled`, `rel_owner`, `unify`) VALUES\n( 1, 'Crust Messaging', true, 0,\n '{\"logo\": \"/applications/crust.jpg\", \"icon\": \"/applications/crust_favicon.png\", \"url\": \"/messaging/\", \"listed\": true}'\n),\n( 2, 'Crust CRM', true, 0,\n '{\"logo\": \"/applications/crust.jpg\", \"icon\": \"/applications/crust_favicon.png\", \"url\": \"/crm/\", \"listed\": true}'\n),\n( 3, 'Crust Admin Area', true, 0,\n '{\"logo\": \"/applications/crust.jpg\", \"icon\": \"/applications/crust_favicon.png\", \"url\": \"/admin/\", \"listed\": true}'\n),\n( 4, 'Corteza Jitsi Bridge', true, 0,\n '{\"logo\": \"/applications/jitsi.png\", \"icon\": \"/applications/jitsi_icon.png\", \"url\": \"/bridge/jitsi/\", \"listed\": true}'\n),\n( 5, 'Google Maps', true, 0,\n '{\"logo\": \"/applications/google_maps.png\", \"icon\": \"/applications/google_maps_icon.png\", \"url\": \"/bridge/google-maps/\", \"listed\": true}'\n);\n\nPK\x07\x08Oi\xd5\xd3\xc6\x05\x00\x00\xc6\x05\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1e\x00 \x0020190326122000.settings.up.sqlUT\x05\x00\x01\x80Cm8DROP TABLE IF EXISTS `settings`;\n\nCREATE TABLE IF NOT EXISTS `sys_settings` (\n rel_owner BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Value owner, 0 for global settings',\n name VARCHAR(200) NOT NULL COMMENT 'Unique set of setting keys',\n value JSON COMMENT 'Setting value',\n\n updated_at DATETIME NOT NULL DEFAULT NOW() COMMENT 'When was the value updated',\n updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Who created/updated the value',\n\n PRIMARY KEY (name, rel_owner)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08`\xcb\x1b\x81t\x02\x00\x00t\x02\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00 \x0020190403113201.users-cleanup.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `sys_user` DROP `password`;\nALTER TABLE `sys_user` DROP `satosa_id`;\nALTER TABLE `sys_credentials` ADD `last_used_at` DATETIME NULL;\nPK\x07\x088\x92\x0fs\x91\x00\x00\x00\x91\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00 \x0020190405090000.internal-auth.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `sys_user` ADD `email_confirmed` BOOLEAN NOT NULL DEFAULT FALSE;\nPK\x07\x08\x8fQs\x8cM\x00\x00\x00M\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00!\x00 \x0020190506090000.compose-app.up.sqlUT\x05\x00\x01\x80Cm8UPDATE `sys_application`\n SET `name` = 'Crust Compose',\n `unify` = '{\"logo\": \"/applications/crust.jpg\", \"icon\": \"/applications/crust_favicon.png\", \"url\": \"/compose/\", \"listed\": true}'\n WHERE id = 2;\nPK\x07\x08\x10\xe9%]\xd0\x00\x00\x00\xd0\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00!\x00 \x0020190506090000.permissions.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS sys_permission_rules (\n rel_role BIGINT UNSIGNED NOT NULL,\n resource VARCHAR(128) NOT NULL,\n operation VARCHAR(128) NOT NULL,\n access TINYINT(1) NOT NULL,\n\n PRIMARY KEY (rel_role, resource, operation)\n) ENGINE=InnoDB;\n\nCREATE TABLE IF NOT EXISTS messaging_permission_rules (\n rel_role BIGINT UNSIGNED NOT NULL,\n resource VARCHAR(128) NOT NULL,\n operation VARCHAR(128) NOT NULL,\n access TINYINT(1) NOT NULL,\n\n PRIMARY KEY (rel_role, resource, operation)\n) ENGINE=InnoDB;\n\nCREATE TABLE IF NOT EXISTS compose_permission_rules (\n rel_role BIGINT UNSIGNED NOT NULL,\n resource VARCHAR(128) NOT NULL,\n operation VARCHAR(128) NOT NULL,\n access TINYINT(1) NOT NULL,\n\n PRIMARY KEY (rel_role, resource, operation)\n) ENGINE=InnoDB;\n\nREPLACE sys_permission_rules\n (rel_role, resource, operation, access)\n SELECT rel_role, resource, operation, `value` - 1 FROM sys_rules WHERE resource LIKE 'system%';\n\nREPLACE compose_permission_rules\n (rel_role, resource, operation, access)\n SELECT rel_role, resource, operation, `value` - 1 FROM sys_rules WHERE resource LIKE 'compose%';\n\nREPLACE messaging_permission_rules\n (rel_role, resource, operation, access)\n SELECT rel_role, resource, operation, `value` - 1 FROM sys_rules WHERE resource LIKE 'messaging%';\n\nDROP TABLE sys_rules;\nPK\x07\x08\x08\xd4\xe0+e\x05\x00\x00e\x05\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00*\x00 \x0020190826085348.migrate-gplus-google.up.sqlUT\x05\x00\x01\x80Cm8/* migrates existing credentials */\nUPDATE sys_credentials SET kind = 'google' WHERE kind = 'gplus';\n\n/* migrates existing settings. */\nUPDATE sys_settings SET name = REPLACE(name, '.gplus.', '.google.') WHERE name LIKE 'auth.external.providers.gplus.%';\nPK\x07\x08<\xac\xedE\xff\x00\x00\x00\xff\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 \x00 \x0020190902080000.automation.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS sys_automation_script (\n `id` BIGINT(20) UNSIGNED NOT NULL,\n `rel_namespace` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'For compatibility only, not used',\n `name` VARCHAR(64) NOT NULL DEFAULT 'unnamed' COMMENT 'The name of the script',\n `source` TEXT NOT NULL COMMENT 'Source code for the script',\n `source_ref` VARCHAR(200) NOT NULL COMMENT 'Where is the script located (if remote)',\n `async` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Do we run this script asynchronously?',\n `rel_runner` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Who is running the script? 0 for invoker',\n `run_in_ua` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Run this script inside user-agent environment',\n `timeout` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Any explicit timeout set for this script (milliseconds)?',\n `critical` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'Is it critical that this script is executed successfully',\n `enabled` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'Is this script enabled?',\n\n `created_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updated_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `updated_at` DATETIME NULL DEFAULT NULL,\n `deleted_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `deleted_at` DATETIME NULL DEFAULT NULL,\n\n PRIMARY KEY (`id`)\n\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE IF NOT EXISTS sys_automation_trigger (\n `id` BIGINT(20) UNSIGNED NOT NULL,\n `rel_script` BIGINT(20) UNSIGNED NOT NULL COMMENT 'Script that is triggered',\n\n `resource` VARCHAR(128) NOT NULL COMMENT 'Resource triggering the event',\n `event` VARCHAR(128) NOT NULL COMMENT 'Event triggered',\n `event_condition`\n TEXT NOT NULL COMMENT 'Trigger condition',\n `enabled` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'Trigger enabled?',\n\n `weight` INT NOT NULL DEFAULT 0,\n\n `created_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updated_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `updated_at` DATETIME NULL DEFAULT NULL,\n `deleted_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `deleted_at` DATETIME NULL DEFAULT NULL,\n\n CONSTRAINT `fk_sys_automation_script` FOREIGN KEY (`rel_script`) REFERENCES `sys_automation_script` (`id`),\n\n PRIMARY KEY (`id`)\n\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\xac\xbb\x1b\x07i\x0b\x00\x00i\x0b\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1f\x00 \x0020190924093443.reminders.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS sys_reminder (\n `id` BIGINT(20) UNSIGNED NOT NULL,\n `resource` VARCHAR(128) NOT NULL COMMENT 'Resource, that this reminder is bound to',\n `payload` JSON NOT NULL COMMENT 'Payload for this reminder',\n `snooze_count` INT NOT NULL DEFAULT 0 COMMENT 'Number of times this reminder was snoozed',\n\n `assigned_to` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Assignee for this reminder',\n `assigned_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'User that assigned this reminder',\n `assigned_at` DATETIME NOT NULL COMMENT 'When the reminder was assigned',\n\n `dismissed_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'User that dismissed this reminder',\n `dismissed_at` DATETIME NULL DEFAULT NULL COMMENT 'Time the reminder was dismissed',\n\n `remind_at` DATETIME NULL DEFAULT NULL COMMENT 'Time the user should be reminded',\n\n `created_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updated_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `updated_at` DATETIME NULL DEFAULT NULL,\n `deleted_by` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,\n `deleted_at` DATETIME NULL DEFAULT NULL,\n\n PRIMARY KEY (`id`)\n\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\n\x10\"\x05X\x06\x00\x00X\x06\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00&\x00 \x0020191023213030.settings-cleanup.up.sqlUT\x05\x00\x01\x80Cm8UPDATE `sys_settings` SET `name` = 'general.mail.logo' WHERE `rel_owner` = 0 AND `name` = 'system.defaultLogo';\nUPDATE `sys_settings` SET `name` = 'general.mail.header.en' WHERE `rel_owner` = 0 AND `name` = 'system.mail.header.en';\nUPDATE `sys_settings` SET `name` = 'general.mail.footer.en' WHERE `rel_owner` = 0 AND `name` = 'system.mail.footer.en';\nPK\x07\x08\x98\xd0\xdcje\x01\x00\x00e\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 \x00 \x0020200419125927.attachment.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS sys_attachment (\n id BIGINT UNSIGNED NOT NULL,\n rel_owner BIGINT UNSIGNED NOT NULL,\n\n kind VARCHAR(32) NOT NULL,\n\n url VARCHAR(512),\n preview_url VARCHAR(512),\n\n size INT UNSIGNED,\n mimetype VARCHAR(255),\n name TEXT,\n\n meta JSON,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n deleted_at DATETIME NULL,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nPK\x07\x08\xca\xba\xa1l=\x02\x00\x00=\x02\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00 \x00migrations.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS `migrations` (\n `project` varchar(16) NOT NULL COMMENT 'sam, crm, ...',\n `filename` varchar(255) NOT NULL COMMENT 'yyyymmddHHMMSS.sql',\n `statement_index` int(11) NOT NULL COMMENT 'Statement number from SQL file',\n `status` TEXT NOT NULL COMMENT 'ok or full error message',\n PRIMARY KEY (`project`,`filename`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nPK\x07\x08\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x06\x00 \x00new.shUT\x05\x00\x01\x80Cm8#!/bin/bash\ntouch $(date +%Y%m%d%H%M%S).up.sqlPK\x07\x08s\xd4N*.\x00\x00\x00.\x00\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xedzU\x8am \x00\x00m \x00\x00\x1a\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xf2\xc4\x87\xe8\xb5\x00\x00\x00\xb5\x00\x00\x00.\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xbe \x00\x0020181124181811.rename_and_prefix_tables.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(9\xa0\xdat8\x01\x00\x008\x01\x00\x00-\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xd8\n\x00\x0020181125100429.add_user_kind_and_owner.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x0d\xf9\xd3ga\x00\x00\x00a\x00\x00\x00-\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81t\x0c\x00\x0020181125153544.satosa_index_not_unique.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(f\x1f\x08\xd0\x9a\x03\x00\x00\x9a\x03\x00\x00!\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x819\x0d\x00\x0020181208140000.credentials.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(0V\x13\x0f4\x00\x00\x004\x00\x00\x00)\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81+\x11\x00\x0020190103203201.users-password-null.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x05\x10[\x91\x05\x01\x00\x00\x05\x01\x00\x00\x1b\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xbf\x11\x00\x0020190116102104.rules.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(s-\x98\xd0\x13\x01\x00\x00\x13\x01\x00\x00)\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x16\x13\x00\x0020190221001051.rename-team-to-role.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x06RHi{\x00\x00\x00{\x00\x00\x00,\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x89\x14\x00\x0020190226160000.system_roles_and_rules.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(Oi\xd5\xd3\xc6\x05\x00\x00\xc6\x05\x00\x00\"\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81g\x15\x00\x0020190306205033.applications.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(`\xcb\x1b\x81t\x02\x00\x00t\x02\x00\x00\x1e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x86\x1b\x00\x0020190326122000.settings.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(8\x92\x0fs\x91\x00\x00\x00\x91\x00\x00\x00#\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81O\x1e\x00\x0020190403113201.users-cleanup.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x8fQs\x8cM\x00\x00\x00M\x00\x00\x00#\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81:\x1f\x00\x0020190405090000.internal-auth.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x10\xe9%]\xd0\x00\x00\x00\xd0\x00\x00\x00!\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xe1\x1f\x00\x0020190506090000.compose-app.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x08\xd4\xe0+e\x05\x00\x00e\x05\x00\x00!\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81 !\x00\x0020190506090000.permissions.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(<\xac\xedE\xff\x00\x00\x00\xff\x00\x00\x00*\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xc6&\x00\x0020190826085348.migrate-gplus-google.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xac\xbb\x1b\x07i\x0b\x00\x00i\x0b\x00\x00 \x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81&(\x00\x0020190902080000.automation.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\n\x10\"\x05X\x06\x00\x00X\x06\x00\x00\x1f\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xe63\x00\x0020190924093443.reminders.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x98\xd0\xdcje\x01\x00\x00e\x01\x00\x00&\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x94:\x00\x0020191023213030.settings-cleanup.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xca\xba\xa1l=\x02\x00\x00=\x02\x00\x00 \x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81V<\x00\x0020200419125927.attachment.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00\x0e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xea>\x00\x00migrations.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(s\xd4N*.\x00\x00\x00.\x00\x00\x00\x06\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xed\x81\xa7@\x00\x00new.shUT\x05\x00\x01\x80Cm8PK\x05\x06\x00\x00\x00\x00\x16\x00\x16\x00\xa1\x07\x00\x00\x12A\x00\x00\x00\x00" diff --git a/system/db/schema/mysql/20200419125927.attachment.up.sql b/system/db/schema/mysql/20200419125927.attachment.up.sql new file mode 100644 index 000000000..17422a15c --- /dev/null +++ b/system/db/schema/mysql/20200419125927.attachment.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS sys_attachment ( + id BIGINT UNSIGNED NOT NULL, + rel_owner BIGINT UNSIGNED NOT NULL, + + kind VARCHAR(32) NOT NULL, + + url VARCHAR(512), + preview_url VARCHAR(512), + + size INT UNSIGNED, + mimetype VARCHAR(255), + name TEXT, + + meta JSON, + + created_at DATETIME NOT NULL DEFAULT NOW(), + updated_at DATETIME NULL, + deleted_at DATETIME NULL, + + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + diff --git a/system/repository/attachment.go b/system/repository/attachment.go new file mode 100644 index 000000000..86c7be349 --- /dev/null +++ b/system/repository/attachment.go @@ -0,0 +1,139 @@ +package repository + +import ( + "context" + "time" + + "github.com/Masterminds/squirrel" + "github.com/pkg/errors" + "github.com/titpetric/factory" + + "github.com/cortezaproject/corteza-server/pkg/rh" + "github.com/cortezaproject/corteza-server/system/types" +) + +type ( + AttachmentRepository interface { + With(ctx context.Context, db *factory.DB) AttachmentRepository + + Find(filter types.AttachmentFilter) (types.AttachmentSet, types.AttachmentFilter, error) + FindByID(attachmentID uint64) (*types.Attachment, error) + Create(mod *types.Attachment) (*types.Attachment, error) + DeleteByID(attachmentID uint64) error + } + + attachment struct { + *repository + } +) + +const ( + ErrAttachmentNotFound = repositoryError("AttachmentNotFound") +) + +func Attachment(ctx context.Context, db *factory.DB) AttachmentRepository { + return (&attachment{}).With(ctx, db) +} + +func (r attachment) With(ctx context.Context, db *factory.DB) AttachmentRepository { + return &attachment{ + repository: r.repository.With(ctx, db), + } +} + +func (r attachment) table() string { + return "sys_attachment" +} + +func (r attachment) columns() []string { + return []string{ + "a.id", + "a.rel_owner", + "a.kind", + "a.url", + "a.preview_url", + "a.name", + "a.meta", + "a.created_at", + "a.updated_at", + "a.deleted_at", + } +} + +func (r attachment) query() squirrel.SelectBuilder { + return squirrel. + Select(r.columns()...). + From(r.table() + " AS a"). + Where("a.deleted_at IS NULL") + +} + +func (r attachment) FindByID(attachmentID uint64) (*types.Attachment, error) { + return r.findOneBy("id", attachmentID) +} + +func (r attachment) findOneBy(field string, value interface{}) (*types.Attachment, error) { + var ( + p = &types.Attachment{} + q = r.query(). + Where(squirrel.Eq{field: value}) + + err = rh.FetchOne(r.db(), q, p) + ) + + if err != nil { + return nil, err + } else if p.ID == 0 { + return nil, ErrAttachmentNotFound + } + + return p, nil +} + +func (r attachment) Find(filter types.AttachmentFilter) (set types.AttachmentSet, f types.AttachmentFilter, err error) { + f = filter + + if f.Sort == "" { + f.Sort = "id ASC" + } + + query := r.query(). + Where(squirrel.Eq{"a.kind": f.Kind}) + + if f.Filter != "" { + err = errors.New("filtering by filter not implemented") + return + } + + var orderBy []string + if orderBy, err = rh.ParseOrder(f.Sort, r.columns()...); err != nil { + return + } else { + query = query.OrderBy(orderBy...) + } + + if f.Count, err = rh.Count(r.db(), query); err != nil || f.Count == 0 { + return + } + + return set, f, rh.FetchPaged(r.db(), query, f.PageFilter, &set) +} + +func (r attachment) Create(mod *types.Attachment) (*types.Attachment, error) { + if mod.ID == 0 { + mod.ID = factory.Sonyflake.NextID() + } + + mod.CreatedAt = time.Now() + + return mod, r.db().Insert(r.table(), mod) +} + +func (r attachment) DeleteByID(attachmentID uint64) error { + _, err := r.db().Exec( + "UPDATE "+r.table()+" SET deleted_at = NOW() WHERE id = ?", + attachmentID, + ) + + return err +} diff --git a/system/rest/attachment.go b/system/rest/attachment.go new file mode 100644 index 000000000..ff4cda6fe --- /dev/null +++ b/system/rest/attachment.go @@ -0,0 +1,189 @@ +package rest + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/titpetric/factory/resputil" + + "github.com/cortezaproject/corteza-server/pkg/auth" + "github.com/cortezaproject/corteza-server/system/rest/request" + "github.com/cortezaproject/corteza-server/system/service" + "github.com/cortezaproject/corteza-server/system/types" + + "github.com/pkg/errors" +) + +var _ = errors.Wrap + +type ( + attachmentPayload struct { + *types.Attachment + } + + attachmentSetPayload struct { + Filter types.AttachmentFilter `json:"filter"` + Set []*attachmentPayload `json:"set"` + } + + Attachment struct { + attachment service.AttachmentService + } +) + +func (Attachment) New() *Attachment { + return &Attachment{ + attachment: service.DefaultAttachment, + } +} + +func (ctrl Attachment) Read(ctx context.Context, r *request.AttachmentRead) (interface{}, error) { + if !auth.GetIdentityFromContext(ctx).Valid() { + return nil, errors.New("Unauthorized") + } + + a, err := ctrl.attachment.With(ctx).FindByID(r.AttachmentID) + return makeAttachmentPayload(ctx, a, err) +} + +func (ctrl Attachment) Delete(ctx context.Context, r *request.AttachmentDelete) (interface{}, error) { + if !auth.GetIdentityFromContext(ctx).Valid() { + return nil, errors.New("Unauthorized") + } + + _, err := ctrl.attachment.With(ctx).FindByID(r.AttachmentID) + if err != nil { + return nil, err + } + + return resputil.OK(), ctrl.attachment.With(ctx).DeleteByID(r.AttachmentID) +} + +func (ctrl Attachment) Original(ctx context.Context, r *request.AttachmentOriginal) (interface{}, error) { + if err := ctrl.isAccessible(r.Kind, r.AttachmentID, r.UserID, r.Sign); err != nil { + return nil, err + } + + return ctrl.serve(ctx, r.AttachmentID, false, r.Download) +} + +func (ctrl Attachment) Preview(ctx context.Context, r *request.AttachmentPreview) (interface{}, error) { + if err := ctrl.isAccessible(r.Kind, r.AttachmentID, r.UserID, r.Sign); err != nil { + return nil, err + } + + return ctrl.serve(ctx, r.AttachmentID, true, false) +} + +func (ctrl Attachment) isAccessible(kind string, attachmentID, userID uint64, signature string) error { + if kind == types.AttachmentKindSettings { + // Attachments on settings are public + return nil + } + + if signature == "" { + return errors.New("Unauthorized") + } + + if userID == 0 { + return errors.New("missing or invalid user ID") + } + + if attachmentID == 0 { + return errors.New("missing or invalid attachment ID") + } + + if !auth.DefaultSigner.Verify(signature, userID, attachmentID) { + return errors.New("missing or invalid signature") + } + + return nil +} + +func (ctrl Attachment) serve(ctx context.Context, attachmentID uint64, preview, download bool) (interface{}, error) { + return func(w http.ResponseWriter, req *http.Request) { + att, err := ctrl.attachment.With(ctx).FindByID(attachmentID) + if err != nil { + // Simplify error handling for now + w.WriteHeader(http.StatusNotFound) + return + } + + var fh io.ReadSeeker + + if preview { + fh, err = ctrl.attachment.OpenPreview(att) + } else { + fh, err = ctrl.attachment.OpenOriginal(att) + } + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + name := url.QueryEscape(att.Name) + + if download { + w.Header().Add("Content-Disposition", "attachment; filename="+name) + } else { + w.Header().Add("Content-Disposition", "inline; filename="+name) + } + + http.ServeContent(w, req, name, att.CreatedAt, fh) + }, nil +} + +func (ctrl Attachment) makeFilterPayload(ctx context.Context, aa types.AttachmentSet, f types.AttachmentFilter, err error) (*attachmentSetPayload, error) { + if err != nil { + return nil, err + } + + asp := &attachmentSetPayload{Filter: f, Set: make([]*attachmentPayload, len(aa))} + + for i := range aa { + asp.Set[i], _ = makeAttachmentPayload(ctx, aa[i], nil) + } + + return asp, nil +} + +func makeAttachmentPayload(ctx context.Context, a *types.Attachment, err error) (*attachmentPayload, error) { + if err != nil || a == nil { + return nil, err + } + + var ( + userID = auth.GetIdentityFromContext(ctx).Identity() + signParams = "" + + preview string + baseURL = fmt.Sprintf("/attachment/%s/%d/", a.Kind, a.ID) + ) + + if a.Meta.Preview != nil { + var ext = a.Meta.Preview.Extension + if ext == "" { + ext = "jpg" + } + + preview = baseURL + fmt.Sprintf("preview.%s", ext) + } + + switch a.Kind { + case types.AttachmentKindSettings: + // No URL signing, settings attachments are public. + default: + signParams = fmt.Sprintf("?sign=%s&userID=%d", auth.DefaultSigner.Sign(userID, a.ID), userID) + } + + ap := &attachmentPayload{a} + + ap.Url = baseURL + fmt.Sprintf("original/%s", url.PathEscape(a.Name)) + signParams + ap.PreviewUrl = preview + signParams + + return ap, nil +} diff --git a/system/rest/handlers/attachment.go b/system/rest/handlers/attachment.go new file mode 100644 index 000000000..3160409e0 --- /dev/null +++ b/system/rest/handlers/attachment.go @@ -0,0 +1,139 @@ +package handlers + +/* + Hello! This file is auto-generated from `docs/src/spec.json`. + + For development: + In order to update the generated files, edit this file under the location, + add your struct fields, imports, API definitions and whatever you want, and: + + 1. run [spec](https://github.com/titpetric/spec) in the same folder, + 2. run `./_gen.php` in this folder. + + You may edit `attachment.go`, `attachment.util.go` or `attachment_test.go` to + implement your API calls, helper functions and tests. The file `attachment.go` + is only generated the first time, and will not be overwritten if it exists. +*/ + +import ( + "context" + + "net/http" + + "github.com/go-chi/chi" + "github.com/titpetric/factory/resputil" + + "github.com/cortezaproject/corteza-server/pkg/logger" + "github.com/cortezaproject/corteza-server/system/rest/request" +) + +// Internal API interface +type AttachmentAPI interface { + Read(context.Context, *request.AttachmentRead) (interface{}, error) + Delete(context.Context, *request.AttachmentDelete) (interface{}, error) + Original(context.Context, *request.AttachmentOriginal) (interface{}, error) + Preview(context.Context, *request.AttachmentPreview) (interface{}, error) +} + +// HTTP API interface +type Attachment struct { + Read func(http.ResponseWriter, *http.Request) + Delete func(http.ResponseWriter, *http.Request) + Original func(http.ResponseWriter, *http.Request) + Preview func(http.ResponseWriter, *http.Request) +} + +func NewAttachment(h AttachmentAPI) *Attachment { + return &Attachment{ + Read: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewAttachmentRead() + if err := params.Fill(r); err != nil { + logger.LogParamError("Attachment.Read", r, err) + resputil.JSON(w, err) + return + } + + value, err := h.Read(r.Context(), params) + if err != nil { + logger.LogControllerError("Attachment.Read", r, err, params.Auditable()) + resputil.JSON(w, err) + return + } + logger.LogControllerCall("Attachment.Read", r, params.Auditable()) + if !serveHTTP(value, w, r) { + resputil.JSON(w, value) + } + }, + Delete: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewAttachmentDelete() + if err := params.Fill(r); err != nil { + logger.LogParamError("Attachment.Delete", r, err) + resputil.JSON(w, err) + return + } + + value, err := h.Delete(r.Context(), params) + if err != nil { + logger.LogControllerError("Attachment.Delete", r, err, params.Auditable()) + resputil.JSON(w, err) + return + } + logger.LogControllerCall("Attachment.Delete", r, params.Auditable()) + if !serveHTTP(value, w, r) { + resputil.JSON(w, value) + } + }, + Original: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewAttachmentOriginal() + if err := params.Fill(r); err != nil { + logger.LogParamError("Attachment.Original", r, err) + resputil.JSON(w, err) + return + } + + value, err := h.Original(r.Context(), params) + if err != nil { + logger.LogControllerError("Attachment.Original", r, err, params.Auditable()) + resputil.JSON(w, err) + return + } + logger.LogControllerCall("Attachment.Original", r, params.Auditable()) + if !serveHTTP(value, w, r) { + resputil.JSON(w, value) + } + }, + Preview: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewAttachmentPreview() + if err := params.Fill(r); err != nil { + logger.LogParamError("Attachment.Preview", r, err) + resputil.JSON(w, err) + return + } + + value, err := h.Preview(r.Context(), params) + if err != nil { + logger.LogControllerError("Attachment.Preview", r, err, params.Auditable()) + resputil.JSON(w, err) + return + } + logger.LogControllerCall("Attachment.Preview", r, params.Auditable()) + if !serveHTTP(value, w, r) { + resputil.JSON(w, value) + } + }, + } +} + +func (h Attachment) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.Handler) { + r.Group(func(r chi.Router) { + r.Use(middlewares...) + r.Get("/attachment/{kind}/{attachmentID}", h.Read) + r.Delete("/attachment/{kind}/{attachmentID}", h.Delete) + r.Get("/attachment/{kind}/{attachmentID}/original/{name}", h.Original) + r.Get("/attachment/{kind}/{attachmentID}/preview.{ext}", h.Preview) + }) +} diff --git a/system/rest/handlers/settings.go b/system/rest/handlers/settings.go index a40ce8d59..a4649459a 100644 --- a/system/rest/handlers/settings.go +++ b/system/rest/handlers/settings.go @@ -32,6 +32,7 @@ type SettingsAPI interface { List(context.Context, *request.SettingsList) (interface{}, error) Update(context.Context, *request.SettingsUpdate) (interface{}, error) Get(context.Context, *request.SettingsGet) (interface{}, error) + Set(context.Context, *request.SettingsSet) (interface{}, error) Current(context.Context, *request.SettingsCurrent) (interface{}, error) } @@ -40,6 +41,7 @@ type Settings struct { List func(http.ResponseWriter, *http.Request) Update func(http.ResponseWriter, *http.Request) Get func(http.ResponseWriter, *http.Request) + Set func(http.ResponseWriter, *http.Request) Current func(http.ResponseWriter, *http.Request) } @@ -105,6 +107,26 @@ func NewSettings(h SettingsAPI) *Settings { resputil.JSON(w, value) } }, + Set: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewSettingsSet() + if err := params.Fill(r); err != nil { + logger.LogParamError("Settings.Set", r, err) + resputil.JSON(w, err) + return + } + + value, err := h.Set(r.Context(), params) + if err != nil { + logger.LogControllerError("Settings.Set", r, err, params.Auditable()) + resputil.JSON(w, err) + return + } + logger.LogControllerCall("Settings.Set", r, params.Auditable()) + if !serveHTTP(value, w, r) { + resputil.JSON(w, value) + } + }, Current: func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() params := request.NewSettingsCurrent() @@ -134,6 +156,7 @@ func (h Settings) MountRoutes(r chi.Router, middlewares ...func(http.Handler) ht r.Get("/settings/", h.List) r.Patch("/settings/", h.Update) r.Get("/settings/{key}", h.Get) + r.Post("/settings/{key}", h.Set) r.Get("/settings/current", h.Current) }) } diff --git a/system/rest/request/attachment.go b/system/rest/request/attachment.go new file mode 100644 index 000000000..f1802134a --- /dev/null +++ b/system/rest/request/attachment.go @@ -0,0 +1,686 @@ +package request + +/* + Hello! This file is auto-generated from `docs/src/spec.json`. + + For development: + In order to update the generated files, edit this file under the location, + add your struct fields, imports, API definitions and whatever you want, and: + + 1. run [spec](https://github.com/titpetric/spec) in the same folder, + 2. run `./_gen.php` in this folder. + + You may edit `attachment.go`, `attachment.util.go` or `attachment_test.go` to + implement your API calls, helper functions and tests. The file `attachment.go` + is only generated the first time, and will not be overwritten if it exists. +*/ + +import ( + "io" + "strings" + + "encoding/json" + "mime/multipart" + "net/http" + + "github.com/go-chi/chi" + "github.com/pkg/errors" +) + +var _ = chi.URLParam +var _ = multipart.FileHeader{} + +// AttachmentRead request parameters +type AttachmentRead struct { + hasAttachmentID bool + rawAttachmentID string + AttachmentID uint64 `json:",string"` + + hasKind bool + rawKind string + Kind string + + hasSign bool + rawSign string + Sign string + + hasUserID bool + rawUserID string + UserID uint64 `json:",string"` +} + +// NewAttachmentRead request +func NewAttachmentRead() *AttachmentRead { + return &AttachmentRead{} +} + +// Auditable returns all auditable/loggable parameters +func (r AttachmentRead) Auditable() map[string]interface{} { + var out = map[string]interface{}{} + + out["attachmentID"] = r.AttachmentID + out["kind"] = r.Kind + out["sign"] = r.Sign + out["userID"] = r.UserID + + return out +} + +// Fill processes request and fills internal variables +func (r *AttachmentRead) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = req.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := req.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := req.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + r.hasAttachmentID = true + r.rawAttachmentID = chi.URLParam(req, "attachmentID") + r.AttachmentID = parseUInt64(chi.URLParam(req, "attachmentID")) + r.hasKind = true + r.rawKind = chi.URLParam(req, "kind") + r.Kind = chi.URLParam(req, "kind") + if val, ok := get["sign"]; ok { + r.hasSign = true + r.rawSign = val + r.Sign = val + } + if val, ok := get["userID"]; ok { + r.hasUserID = true + r.rawUserID = val + r.UserID = parseUInt64(val) + } + + return err +} + +var _ RequestFiller = NewAttachmentRead() + +// AttachmentDelete request parameters +type AttachmentDelete struct { + hasAttachmentID bool + rawAttachmentID string + AttachmentID uint64 `json:",string"` + + hasKind bool + rawKind string + Kind string + + hasSign bool + rawSign string + Sign string + + hasUserID bool + rawUserID string + UserID uint64 `json:",string"` +} + +// NewAttachmentDelete request +func NewAttachmentDelete() *AttachmentDelete { + return &AttachmentDelete{} +} + +// Auditable returns all auditable/loggable parameters +func (r AttachmentDelete) Auditable() map[string]interface{} { + var out = map[string]interface{}{} + + out["attachmentID"] = r.AttachmentID + out["kind"] = r.Kind + out["sign"] = r.Sign + out["userID"] = r.UserID + + return out +} + +// Fill processes request and fills internal variables +func (r *AttachmentDelete) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = req.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := req.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := req.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + r.hasAttachmentID = true + r.rawAttachmentID = chi.URLParam(req, "attachmentID") + r.AttachmentID = parseUInt64(chi.URLParam(req, "attachmentID")) + r.hasKind = true + r.rawKind = chi.URLParam(req, "kind") + r.Kind = chi.URLParam(req, "kind") + if val, ok := get["sign"]; ok { + r.hasSign = true + r.rawSign = val + r.Sign = val + } + if val, ok := get["userID"]; ok { + r.hasUserID = true + r.rawUserID = val + r.UserID = parseUInt64(val) + } + + return err +} + +var _ RequestFiller = NewAttachmentDelete() + +// AttachmentOriginal request parameters +type AttachmentOriginal struct { + hasDownload bool + rawDownload string + Download bool + + hasSign bool + rawSign string + Sign string + + hasUserID bool + rawUserID string + UserID uint64 `json:",string"` + + hasAttachmentID bool + rawAttachmentID string + AttachmentID uint64 `json:",string"` + + hasName bool + rawName string + Name string + + hasKind bool + rawKind string + Kind string +} + +// NewAttachmentOriginal request +func NewAttachmentOriginal() *AttachmentOriginal { + return &AttachmentOriginal{} +} + +// Auditable returns all auditable/loggable parameters +func (r AttachmentOriginal) Auditable() map[string]interface{} { + var out = map[string]interface{}{} + + out["download"] = r.Download + out["sign"] = r.Sign + out["userID"] = r.UserID + out["attachmentID"] = r.AttachmentID + out["name"] = r.Name + out["kind"] = r.Kind + + return out +} + +// Fill processes request and fills internal variables +func (r *AttachmentOriginal) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = req.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := req.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := req.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + if val, ok := get["download"]; ok { + r.hasDownload = true + r.rawDownload = val + r.Download = parseBool(val) + } + if val, ok := get["sign"]; ok { + r.hasSign = true + r.rawSign = val + r.Sign = val + } + if val, ok := get["userID"]; ok { + r.hasUserID = true + r.rawUserID = val + r.UserID = parseUInt64(val) + } + r.hasAttachmentID = true + r.rawAttachmentID = chi.URLParam(req, "attachmentID") + r.AttachmentID = parseUInt64(chi.URLParam(req, "attachmentID")) + r.hasName = true + r.rawName = chi.URLParam(req, "name") + r.Name = chi.URLParam(req, "name") + r.hasKind = true + r.rawKind = chi.URLParam(req, "kind") + r.Kind = chi.URLParam(req, "kind") + + return err +} + +var _ RequestFiller = NewAttachmentOriginal() + +// AttachmentPreview request parameters +type AttachmentPreview struct { + hasAttachmentID bool + rawAttachmentID string + AttachmentID uint64 `json:",string"` + + hasExt bool + rawExt string + Ext string + + hasKind bool + rawKind string + Kind string + + hasSign bool + rawSign string + Sign string + + hasUserID bool + rawUserID string + UserID uint64 `json:",string"` +} + +// NewAttachmentPreview request +func NewAttachmentPreview() *AttachmentPreview { + return &AttachmentPreview{} +} + +// Auditable returns all auditable/loggable parameters +func (r AttachmentPreview) Auditable() map[string]interface{} { + var out = map[string]interface{}{} + + out["attachmentID"] = r.AttachmentID + out["ext"] = r.Ext + out["kind"] = r.Kind + out["sign"] = r.Sign + out["userID"] = r.UserID + + return out +} + +// Fill processes request and fills internal variables +func (r *AttachmentPreview) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = req.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := req.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := req.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + r.hasAttachmentID = true + r.rawAttachmentID = chi.URLParam(req, "attachmentID") + r.AttachmentID = parseUInt64(chi.URLParam(req, "attachmentID")) + r.hasExt = true + r.rawExt = chi.URLParam(req, "ext") + r.Ext = chi.URLParam(req, "ext") + r.hasKind = true + r.rawKind = chi.URLParam(req, "kind") + r.Kind = chi.URLParam(req, "kind") + if val, ok := get["sign"]; ok { + r.hasSign = true + r.rawSign = val + r.Sign = val + } + if val, ok := get["userID"]; ok { + r.hasUserID = true + r.rawUserID = val + r.UserID = parseUInt64(val) + } + + return err +} + +var _ RequestFiller = NewAttachmentPreview() + +// HasAttachmentID returns true if attachmentID was set +func (r *AttachmentRead) HasAttachmentID() bool { + return r.hasAttachmentID +} + +// RawAttachmentID returns raw value of attachmentID parameter +func (r *AttachmentRead) RawAttachmentID() string { + return r.rawAttachmentID +} + +// GetAttachmentID returns casted value of attachmentID parameter +func (r *AttachmentRead) GetAttachmentID() uint64 { + return r.AttachmentID +} + +// HasKind returns true if kind was set +func (r *AttachmentRead) HasKind() bool { + return r.hasKind +} + +// RawKind returns raw value of kind parameter +func (r *AttachmentRead) RawKind() string { + return r.rawKind +} + +// GetKind returns casted value of kind parameter +func (r *AttachmentRead) GetKind() string { + return r.Kind +} + +// HasSign returns true if sign was set +func (r *AttachmentRead) HasSign() bool { + return r.hasSign +} + +// RawSign returns raw value of sign parameter +func (r *AttachmentRead) RawSign() string { + return r.rawSign +} + +// GetSign returns casted value of sign parameter +func (r *AttachmentRead) GetSign() string { + return r.Sign +} + +// HasUserID returns true if userID was set +func (r *AttachmentRead) HasUserID() bool { + return r.hasUserID +} + +// RawUserID returns raw value of userID parameter +func (r *AttachmentRead) RawUserID() string { + return r.rawUserID +} + +// GetUserID returns casted value of userID parameter +func (r *AttachmentRead) GetUserID() uint64 { + return r.UserID +} + +// HasAttachmentID returns true if attachmentID was set +func (r *AttachmentDelete) HasAttachmentID() bool { + return r.hasAttachmentID +} + +// RawAttachmentID returns raw value of attachmentID parameter +func (r *AttachmentDelete) RawAttachmentID() string { + return r.rawAttachmentID +} + +// GetAttachmentID returns casted value of attachmentID parameter +func (r *AttachmentDelete) GetAttachmentID() uint64 { + return r.AttachmentID +} + +// HasKind returns true if kind was set +func (r *AttachmentDelete) HasKind() bool { + return r.hasKind +} + +// RawKind returns raw value of kind parameter +func (r *AttachmentDelete) RawKind() string { + return r.rawKind +} + +// GetKind returns casted value of kind parameter +func (r *AttachmentDelete) GetKind() string { + return r.Kind +} + +// HasSign returns true if sign was set +func (r *AttachmentDelete) HasSign() bool { + return r.hasSign +} + +// RawSign returns raw value of sign parameter +func (r *AttachmentDelete) RawSign() string { + return r.rawSign +} + +// GetSign returns casted value of sign parameter +func (r *AttachmentDelete) GetSign() string { + return r.Sign +} + +// HasUserID returns true if userID was set +func (r *AttachmentDelete) HasUserID() bool { + return r.hasUserID +} + +// RawUserID returns raw value of userID parameter +func (r *AttachmentDelete) RawUserID() string { + return r.rawUserID +} + +// GetUserID returns casted value of userID parameter +func (r *AttachmentDelete) GetUserID() uint64 { + return r.UserID +} + +// HasDownload returns true if download was set +func (r *AttachmentOriginal) HasDownload() bool { + return r.hasDownload +} + +// RawDownload returns raw value of download parameter +func (r *AttachmentOriginal) RawDownload() string { + return r.rawDownload +} + +// GetDownload returns casted value of download parameter +func (r *AttachmentOriginal) GetDownload() bool { + return r.Download +} + +// HasSign returns true if sign was set +func (r *AttachmentOriginal) HasSign() bool { + return r.hasSign +} + +// RawSign returns raw value of sign parameter +func (r *AttachmentOriginal) RawSign() string { + return r.rawSign +} + +// GetSign returns casted value of sign parameter +func (r *AttachmentOriginal) GetSign() string { + return r.Sign +} + +// HasUserID returns true if userID was set +func (r *AttachmentOriginal) HasUserID() bool { + return r.hasUserID +} + +// RawUserID returns raw value of userID parameter +func (r *AttachmentOriginal) RawUserID() string { + return r.rawUserID +} + +// GetUserID returns casted value of userID parameter +func (r *AttachmentOriginal) GetUserID() uint64 { + return r.UserID +} + +// HasAttachmentID returns true if attachmentID was set +func (r *AttachmentOriginal) HasAttachmentID() bool { + return r.hasAttachmentID +} + +// RawAttachmentID returns raw value of attachmentID parameter +func (r *AttachmentOriginal) RawAttachmentID() string { + return r.rawAttachmentID +} + +// GetAttachmentID returns casted value of attachmentID parameter +func (r *AttachmentOriginal) GetAttachmentID() uint64 { + return r.AttachmentID +} + +// HasName returns true if name was set +func (r *AttachmentOriginal) HasName() bool { + return r.hasName +} + +// RawName returns raw value of name parameter +func (r *AttachmentOriginal) RawName() string { + return r.rawName +} + +// GetName returns casted value of name parameter +func (r *AttachmentOriginal) GetName() string { + return r.Name +} + +// HasKind returns true if kind was set +func (r *AttachmentOriginal) HasKind() bool { + return r.hasKind +} + +// RawKind returns raw value of kind parameter +func (r *AttachmentOriginal) RawKind() string { + return r.rawKind +} + +// GetKind returns casted value of kind parameter +func (r *AttachmentOriginal) GetKind() string { + return r.Kind +} + +// HasAttachmentID returns true if attachmentID was set +func (r *AttachmentPreview) HasAttachmentID() bool { + return r.hasAttachmentID +} + +// RawAttachmentID returns raw value of attachmentID parameter +func (r *AttachmentPreview) RawAttachmentID() string { + return r.rawAttachmentID +} + +// GetAttachmentID returns casted value of attachmentID parameter +func (r *AttachmentPreview) GetAttachmentID() uint64 { + return r.AttachmentID +} + +// HasExt returns true if ext was set +func (r *AttachmentPreview) HasExt() bool { + return r.hasExt +} + +// RawExt returns raw value of ext parameter +func (r *AttachmentPreview) RawExt() string { + return r.rawExt +} + +// GetExt returns casted value of ext parameter +func (r *AttachmentPreview) GetExt() string { + return r.Ext +} + +// HasKind returns true if kind was set +func (r *AttachmentPreview) HasKind() bool { + return r.hasKind +} + +// RawKind returns raw value of kind parameter +func (r *AttachmentPreview) RawKind() string { + return r.rawKind +} + +// GetKind returns casted value of kind parameter +func (r *AttachmentPreview) GetKind() string { + return r.Kind +} + +// HasSign returns true if sign was set +func (r *AttachmentPreview) HasSign() bool { + return r.hasSign +} + +// RawSign returns raw value of sign parameter +func (r *AttachmentPreview) RawSign() string { + return r.rawSign +} + +// GetSign returns casted value of sign parameter +func (r *AttachmentPreview) GetSign() string { + return r.Sign +} + +// HasUserID returns true if userID was set +func (r *AttachmentPreview) HasUserID() bool { + return r.hasUserID +} + +// RawUserID returns raw value of userID parameter +func (r *AttachmentPreview) RawUserID() string { + return r.rawUserID +} + +// GetUserID returns casted value of userID parameter +func (r *AttachmentPreview) GetUserID() uint64 { + return r.UserID +} diff --git a/system/rest/request/settings.go b/system/rest/request/settings.go index bb2cd7ca6..a5f7d8458 100644 --- a/system/rest/request/settings.go +++ b/system/rest/request/settings.go @@ -214,6 +214,85 @@ func (r *SettingsGet) Fill(req *http.Request) (err error) { var _ RequestFiller = NewSettingsGet() +// SettingsSet request parameters +type SettingsSet struct { + hasKey bool + rawKey string + Key string + + hasUpload bool + rawUpload string + Upload *multipart.FileHeader + + hasOwnerID bool + rawOwnerID string + OwnerID uint64 `json:",string"` +} + +// NewSettingsSet request +func NewSettingsSet() *SettingsSet { + return &SettingsSet{} +} + +// Auditable returns all auditable/loggable parameters +func (r SettingsSet) Auditable() map[string]interface{} { + var out = map[string]interface{}{} + + out["key"] = r.Key + out["upload.size"] = r.Upload.Size + out["upload.filename"] = r.Upload.Filename + + out["ownerID"] = r.OwnerID + + return out +} + +// Fill processes request and fills internal variables +func (r *SettingsSet) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = req.ParseMultipartForm(32 << 20); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := req.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := req.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + r.hasKey = true + r.rawKey = chi.URLParam(req, "key") + r.Key = chi.URLParam(req, "key") + if _, r.Upload, err = req.FormFile("upload"); err != nil { + return errors.Wrap(err, "error processing uploaded file") + } + + if val, ok := post["ownerID"]; ok { + r.hasOwnerID = true + r.rawOwnerID = val + r.OwnerID = parseUInt64(val) + } + + return err +} + +var _ RequestFiller = NewSettingsSet() + // SettingsCurrent request parameters type SettingsCurrent struct { } @@ -322,3 +401,48 @@ func (r *SettingsGet) RawKey() string { func (r *SettingsGet) GetKey() string { return r.Key } + +// HasKey returns true if key was set +func (r *SettingsSet) HasKey() bool { + return r.hasKey +} + +// RawKey returns raw value of key parameter +func (r *SettingsSet) RawKey() string { + return r.rawKey +} + +// GetKey returns casted value of key parameter +func (r *SettingsSet) GetKey() string { + return r.Key +} + +// HasUpload returns true if upload was set +func (r *SettingsSet) HasUpload() bool { + return r.hasUpload +} + +// RawUpload returns raw value of upload parameter +func (r *SettingsSet) RawUpload() string { + return r.rawUpload +} + +// GetUpload returns casted value of upload parameter +func (r *SettingsSet) GetUpload() *multipart.FileHeader { + return r.Upload +} + +// HasOwnerID returns true if ownerID was set +func (r *SettingsSet) HasOwnerID() bool { + return r.hasOwnerID +} + +// RawOwnerID returns raw value of ownerID parameter +func (r *SettingsSet) RawOwnerID() string { + return r.rawOwnerID +} + +// GetOwnerID returns casted value of ownerID parameter +func (r *SettingsSet) GetOwnerID() uint64 { + return r.OwnerID +} diff --git a/system/rest/router.go b/system/rest/router.go index 84c70ce1b..51f00c298 100644 --- a/system/rest/router.go +++ b/system/rest/router.go @@ -12,6 +12,7 @@ func MountRoutes(r chi.Router) { NewExternalAuth().ApiServerRoutes(r) r.Group(func(r chi.Router) { + handlers.NewAttachment(Attachment{}.New()).MountRoutes(r) handlers.NewAuth((Auth{}).New()).MountRoutes(r) handlers.NewAuthInternal((AuthInternal{}).New()).MountRoutes(r) diff --git a/system/rest/settings.go b/system/rest/settings.go index 7cc3a4cb8..b033ca0e8 100644 --- a/system/rest/settings.go +++ b/system/rest/settings.go @@ -2,16 +2,18 @@ package rest import ( "context" - + "fmt" "github.com/cortezaproject/corteza-server/pkg/settings" "github.com/cortezaproject/corteza-server/system/rest/request" "github.com/cortezaproject/corteza-server/system/service" + "strconv" ) type ( Settings struct { svc struct { settings settings.Service + att service.AttachmentService } } ) @@ -19,6 +21,7 @@ type ( func (Settings) New() *Settings { ctrl := &Settings{} ctrl.svc.settings = service.DefaultSettings + ctrl.svc.att = service.DefaultAttachment return ctrl } @@ -47,6 +50,37 @@ func (ctrl *Settings) Get(ctx context.Context, r *request.SettingsGet) (interfac } } +func (ctrl *Settings) Set(ctx context.Context, r *request.SettingsSet) (interface{}, error) { + if r.Upload != nil { + file, err := r.Upload.Open() + + if err != nil { + return nil, err + } + + defer file.Close() + + // @todo this whole attachment + settings logic must be moved to settings service + // this can be done when we generalize attachment handling + // and move that our of sys/msg/cmp to pkg + att, err := ctrl.svc.att.With(ctx).CreateSettingsAttachment( + r.Upload.Filename, + r.Upload.Size, + file, + map[string]string{"key": r.Key, "ownedBy": strconv.FormatUint(r.OwnerID, 10)}, + ) + + s := &settings.Value{Name: r.Key, OwnedBy: r.OwnerID} + if err = s.SetValue(fmt.Sprintf("attachment:%d", att.ID)); err != nil { + return nil, err + } + + return s, ctrl.svc.settings.Set(ctx, s) + } + + return nil, nil +} + // Current settings, structured // // This is available to all authenticated users diff --git a/system/service/attachment.go b/system/service/attachment.go new file mode 100644 index 000000000..59b2f916e --- /dev/null +++ b/system/service/attachment.go @@ -0,0 +1,308 @@ +package service + +import ( + "bytes" + "context" + "github.com/cortezaproject/corteza-server/pkg/settings" + "image" + "image/gif" + "io" + "net/http" + "path" + "strings" + + "github.com/disintegration/imaging" + "github.com/edwvee/exiffix" + "github.com/pkg/errors" + "github.com/titpetric/factory" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + intAuth "github.com/cortezaproject/corteza-server/pkg/auth" + "github.com/cortezaproject/corteza-server/pkg/logger" + "github.com/cortezaproject/corteza-server/pkg/store" + "github.com/cortezaproject/corteza-server/system/repository" + "github.com/cortezaproject/corteza-server/system/types" +) + +const ( + attachmentPreviewMaxWidth = 320 + attachmentPreviewMaxHeight = 180 +) + +type ( + attachment struct { + db *factory.DB + ctx context.Context + logger *zap.Logger + + store store.Store + + ac attachmentAccessController + + settingsSvc settings.Service + + attachment repository.AttachmentRepository + } + + attachmentAccessController interface { + CanManageSettings(context.Context) bool + } + + AttachmentService interface { + With(ctx context.Context) AttachmentService + + FindByID(attachmentID uint64) (*types.Attachment, error) + Find(filter types.AttachmentFilter) (types.AttachmentSet, types.AttachmentFilter, error) + CreateSettingsAttachment(name string, size int64, fh io.ReadSeeker, labels map[string]string) (*types.Attachment, error) + OpenOriginal(att *types.Attachment) (io.ReadSeeker, error) + OpenPreview(att *types.Attachment) (io.ReadSeeker, error) + DeleteByID(attachmentID uint64) error + } +) + +func Attachment(store store.Store) AttachmentService { + return (&attachment{ + logger: DefaultLogger.Named("attachment"), + store: store, + ac: DefaultAccessControl, + + settingsSvc: DefaultSettings, + }).With(context.Background()) +} + +func (svc attachment) With(ctx context.Context) AttachmentService { + db := repository.DB(ctx) + return &attachment{ + db: db, + ctx: ctx, + logger: svc.logger, + + ac: svc.ac, + + settingsSvc: svc.settingsSvc, + + store: svc.store, + + attachment: repository.Attachment(ctx, db), + } +} + +// log() returns zap's logger with requestID from current context and fields. +func (svc attachment) log(fields ...zapcore.Field) *zap.Logger { + return logger.AddRequestID(svc.ctx, svc.logger).With(fields...) +} + +func (svc attachment) FindByID(attachmentID uint64) (*types.Attachment, error) { + return svc.attachment.FindByID(attachmentID) +} + +func (svc attachment) DeleteByID(attachmentID uint64) error { + return svc.attachment.DeleteByID(attachmentID) +} + +func (svc attachment) Find(filter types.AttachmentFilter) (types.AttachmentSet, types.AttachmentFilter, error) { + return svc.attachment.Find(filter) +} + +func (svc attachment) OpenOriginal(att *types.Attachment) (io.ReadSeeker, error) { + if len(att.Url) == 0 { + return nil, nil + } + + return svc.store.Open(att.Url) +} + +func (svc attachment) OpenPreview(att *types.Attachment) (io.ReadSeeker, error) { + if len(att.PreviewUrl) == 0 { + return nil, nil + } + + return svc.store.Open(att.PreviewUrl) +} + +func (svc attachment) CreateSettingsAttachment(name string, size int64, fh io.ReadSeeker, labels map[string]string) (att *types.Attachment, err error) { + var ( + currentUserID uint64 = intAuth.GetIdentityFromContext(svc.ctx).Identity() + ) + + if !svc.ac.CanManageSettings(svc.ctx) { + return nil, ErrNoUpdatePermissions.withStack() + } + + att = &types.Attachment{ + ID: factory.Sonyflake.NextID(), + OwnerID: currentUserID, + Name: strings.TrimSpace(name), + Kind: types.AttachmentKindSettings, + } + + if labels != nil { + att.Meta.Labels = labels + } + + if err = svc.create(name, size, fh, att); err != nil { + return nil, err + } + + return att, err +} + +func (svc attachment) create(name string, size int64, fh io.ReadSeeker, att *types.Attachment) (err error) { + if svc.store == nil { + return errors.New("Can not create attachment: store handler not set") + } + + log := svc.log( + zap.String("name", att.Name), + zap.Int64("size", att.Meta.Original.Size), + ) + + // Extract extension but make sure path.Ext is not confused by any leading/trailing dots + att.Meta.Original.Extension = strings.Trim(path.Ext(strings.Trim(name, ".")), ".") + + att.Meta.Original.Size = size + if att.Meta.Original.Mimetype, err = svc.extractMimetype(fh); err != nil { + log.Error("could not extract mime-type", zap.Error(err)) + return + } + + att.Url = svc.store.Original(att.ID, att.Meta.Original.Extension) + log = log.With(zap.String("url", att.Url)) + + if err = svc.store.Save(att.Url, fh); err != nil { + log.Error("could not store file", zap.Error(err)) + return + } + + // Process image: extract width, height, make preview + err = svc.processImage(fh, att) + if err != nil { + log.Error("could not process image", zap.Error(err)) + } + + return svc.db.Transaction(func() (err error) { + if att, err = svc.attachment.Create(att); err != nil { + return + } + + return nil + }) +} + +func (svc attachment) extractMimetype(file io.ReadSeeker) (mimetype string, err error) { + if _, err = file.Seek(0, 0); err != nil { + return + } + + // Make sure we rewind when we're done + defer file.Seek(0, 0) + + // See http.DetectContentType about 512 bytes + var buf = make([]byte, 512) + if _, err = file.Read(buf); err != nil { + return + } + + return http.DetectContentType(buf), nil +} + +func (svc attachment) processImage(original io.ReadSeeker, att *types.Attachment) (err error) { + if !strings.HasPrefix(att.Meta.Original.Mimetype, "image/") { + // Only supporting previews from images (for now) + return + } + + var ( + preview image.Image + opts []imaging.EncodeOption + format imaging.Format + previewFormat imaging.Format + animated bool + f2m = map[imaging.Format]string{ + imaging.JPEG: "image/jpeg", + imaging.GIF: "image/gif", + } + + f2e = map[imaging.Format]string{ + imaging.JPEG: "jpg", + imaging.GIF: "gif", + } + ) + + if _, err = original.Seek(0, 0); err != nil { + return + } + + if format, err = imaging.FormatFromExtension(att.Meta.Original.Extension); err != nil { + return errors.Wrapf(err, "Could not get format from extension '%s'", att.Meta.Original.Extension) + } + + previewFormat = format + + if imaging.JPEG == format { + // Rotate image if needed + // if preview, _, err = exiffix.Decode(original); err != nil { + // return errors.Wrapf(err, "Could not decode EXIF from JPEG") + // } + preview, _, _ = exiffix.Decode(original) + } + + if imaging.GIF == format { + // Decode all and check loops & delay to determine if GIF is animated or not + if cfg, err := gif.DecodeAll(original); err == nil { + animated = cfg.LoopCount > 0 || len(cfg.Delay) > 1 + + // Use first image for the preview + preview = cfg.Image[0] + } else { + return errors.Wrapf(err, "Could not decode gif config") + } + + } else { + // Use GIF preview for GIFs and JPEG for everything else! + previewFormat = imaging.JPEG + + // Store with a bit lower quality + opts = append(opts, imaging.JPEGQuality(85)) + } + + // In case of JPEG we decode the image and rotate it beforehand + // other cases are handled here + if preview == nil { + if preview, err = imaging.Decode(original); err != nil { + return errors.Wrapf(err, "Could not decode original image") + } + } + + var width, height = preview.Bounds().Max.X, preview.Bounds().Max.Y + att.SetOriginalImageMeta(width, height, animated) + + if width > attachmentPreviewMaxWidth && width > height { + // Landscape does not fit + preview = imaging.Resize(preview, attachmentPreviewMaxWidth, 0, imaging.Lanczos) + } else if height > attachmentPreviewMaxHeight { + // Height does not fit + preview = imaging.Resize(preview, 0, attachmentPreviewMaxHeight, imaging.Lanczos) + } + + // Get dimensions from the preview + width, height = preview.Bounds().Max.X, preview.Bounds().Max.Y + + var buf = &bytes.Buffer{} + if err = imaging.Encode(buf, preview, previewFormat, opts...); err != nil { + return + } + + meta := att.SetPreviewImageMeta(width, height, false) + meta.Size = int64(buf.Len()) + meta.Mimetype = f2m[previewFormat] + meta.Extension = f2e[previewFormat] + + // Can and how we make a preview of this attachment? + att.PreviewUrl = svc.store.Preview(att.ID, meta.Extension) + + return svc.store.Save(att.PreviewUrl, buf) +} + +var _ AttachmentService = &attachment{} diff --git a/system/service/service.go b/system/service/service.go index 76b235636..b8f16668f 100644 --- a/system/service/service.go +++ b/system/service/service.go @@ -2,6 +2,9 @@ package service import ( "context" + "github.com/cortezaproject/corteza-server/pkg/store" + "github.com/cortezaproject/corteza-server/pkg/store/minio" + "github.com/cortezaproject/corteza-server/pkg/store/plain" "go.uber.org/zap" @@ -42,6 +45,8 @@ type ( ) var ( + DefaultStore store.Store + DefaultLogger *zap.Logger // CurrentSubscription holds current subscription info, @@ -77,6 +82,7 @@ var ( DefaultOrganisation OrganisationService DefaultApplication ApplicationService DefaultReminder ReminderService + DefaultAttachment AttachmentService DefaultStatistics *statistics ) @@ -99,6 +105,41 @@ func Initialize(ctx context.Context, log *zap.Logger, c Config) (err error) { CurrentSettings, ) + if DefaultStore == nil { + const svcPath = "compose" + if c.Storage.MinioEndpoint != "" { + var bucket = svcPath + if c.Storage.MinioBucket != "" { + bucket = c.Storage.MinioBucket + "/" + svcPath + } + + DefaultStore, err = minio.New(bucket, minio.Options{ + Endpoint: c.Storage.MinioEndpoint, + Secure: c.Storage.MinioSecure, + Strict: c.Storage.MinioStrict, + AccessKeyID: c.Storage.MinioAccessKey, + SecretAccessKey: c.Storage.MinioSecretKey, + + ServerSideEncryptKey: []byte(c.Storage.MinioSSECKey), + }) + + log.Info("initializing minio", + zap.String("bucket", bucket), + zap.String("endpoint", c.Storage.MinioEndpoint), + zap.Error(err)) + } else { + path := c.Storage.Path + "/" + svcPath + DefaultStore, err = plain.New(path) + log.Info("initializing store", + zap.String("path", path), + zap.Error(err)) + } + + if err != nil { + return err + } + } + DefaultAuthNotification = AuthNotification(ctx) DefaultAuth = Auth(ctx) DefaultUser = User(ctx) @@ -108,6 +149,7 @@ func Initialize(ctx context.Context, log *zap.Logger, c Config) (err error) { DefaultReminder = Reminder(ctx) DefaultSink = Sink() DefaultStatistics = Statistics(ctx) + DefaultAttachment = Attachment(DefaultStore) return } diff --git a/system/types/attachment.gen.go b/system/types/attachment.gen.go new file mode 100644 index 000000000..c1850a39a --- /dev/null +++ b/system/types/attachment.gen.go @@ -0,0 +1,67 @@ +package types + +// Hello! This file is auto-generated. + +type ( + + // AttachmentSet slice of Attachment + // + // This type is auto-generated. + AttachmentSet []*Attachment +) + +// Walk iterates through every slice item and calls w(Attachment) err +// +// This function is auto-generated. +func (set AttachmentSet) Walk(w func(*Attachment) error) (err error) { + for i := range set { + if err = w(set[i]); err != nil { + return + } + } + + return +} + +// Filter iterates through every slice item, calls f(Attachment) (bool, err) and return filtered slice +// +// This function is auto-generated. +func (set AttachmentSet) Filter(f func(*Attachment) (bool, error)) (out AttachmentSet, err error) { + var ok bool + out = AttachmentSet{} + for i := range set { + if ok, err = f(set[i]); err != nil { + return + } else if ok { + out = append(out, set[i]) + } + } + + return +} + +// FindByID finds items from slice by its ID property +// +// This function is auto-generated. +func (set AttachmentSet) FindByID(ID uint64) *Attachment { + for i := range set { + if set[i].ID == ID { + return set[i] + } + } + + return nil +} + +// IDs returns a slice of uint64s from all items in the set +// +// This function is auto-generated. +func (set AttachmentSet) IDs() (IDs []uint64) { + IDs = make([]uint64, len(set)) + + for i := range set { + IDs[i] = set[i].ID + } + + return +} diff --git a/system/types/attachment.gen_test.go b/system/types/attachment.gen_test.go new file mode 100644 index 000000000..6f21c5c50 --- /dev/null +++ b/system/types/attachment.gen_test.go @@ -0,0 +1,102 @@ +package types + +import ( + "testing" + + "errors" + + "github.com/stretchr/testify/require" +) + +// Hello! This file is auto-generated. + +func TestAttachmentSetWalk(t *testing.T) { + var ( + value = make(AttachmentSet, 3) + req = require.New(t) + ) + + // check walk with no errors + { + err := value.Walk(func(*Attachment) error { + return nil + }) + req.NoError(err) + } + + // check walk with error + req.Error(value.Walk(func(*Attachment) error { return errors.New("walk error") })) + +} + +func TestAttachmentSetFilter(t *testing.T) { + var ( + value = make(AttachmentSet, 3) + req = require.New(t) + ) + + // filter nothing + { + set, err := value.Filter(func(*Attachment) (bool, error) { + return true, nil + }) + req.NoError(err) + req.Equal(len(set), len(value)) + } + + // filter one item + { + found := false + set, err := value.Filter(func(*Attachment) (bool, error) { + if !found { + found = true + return found, nil + } + return false, nil + }) + req.NoError(err) + req.Len(set, 1) + } + + // filter error + { + _, err := value.Filter(func(*Attachment) (bool, error) { + return false, errors.New("filter error") + }) + req.Error(err) + } +} + +func TestAttachmentSetIDs(t *testing.T) { + var ( + value = make(AttachmentSet, 3) + req = require.New(t) + ) + + // construct objects + value[0] = new(Attachment) + value[1] = new(Attachment) + value[2] = new(Attachment) + // set ids + value[0].ID = 1 + value[1].ID = 2 + value[2].ID = 3 + + // Find existing + { + val := value.FindByID(2) + req.Equal(uint64(2), val.ID) + } + + // Find non-existing + { + val := value.FindByID(4) + req.Nil(val) + } + + // List IDs from set + { + val := value.IDs() + req.Equal(len(val), len(value)) + } +} diff --git a/system/types/attachment.go b/system/types/attachment.go new file mode 100644 index 000000000..07c4a0cb2 --- /dev/null +++ b/system/types/attachment.go @@ -0,0 +1,104 @@ +package types + +import ( + "database/sql/driver" + "encoding/json" + "time" + + "github.com/pkg/errors" + + "github.com/cortezaproject/corteza-server/pkg/rh" +) + +type ( + Attachment struct { + ID uint64 `db:"id" json:"attachmentID,string"` + OwnerID uint64 `db:"rel_owner" json:"ownerID,string"` + Kind string `db:"kind" json:"-"` + Url string `db:"url" json:"url,omitempty"` + PreviewUrl string `db:"preview_url" json:"previewUrl,omitempty"` + Name string `db:"name" json:"name,omitempty"` + Meta attachmentMeta `db:"meta" json:"meta"` + + CreatedAt time.Time `db:"created_at" json:"createdAt,omitempty"` + UpdatedAt *time.Time `db:"updated_at" json:"updatedAt,omitempty"` + DeletedAt *time.Time `db:"deleted_at" json:"deletedAt,omitempty"` + } + + // AttachmentFilter is used for filtering and as a return value from Find + AttachmentFilter struct { + Kind string `json:"kind,omitempty"` + Filter string `json:"filter"` + Sort string `json:"sort"` + + // Standard paging fields & helpers + rh.PageFilter + } + + attachmentImageMeta struct { + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Animated bool `json:"animated"` + } + + attachmentFileMeta struct { + Size int64 `json:"size"` + Extension string `json:"ext"` + Mimetype string `json:"mimetype"` + Image *attachmentImageMeta `json:"image,omitempty"` + } + + attachmentMeta struct { + Original attachmentFileMeta `json:"original"` + Preview *attachmentFileMeta `json:"preview,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + } +) + +const ( + AttachmentKindSettings string = "settings" +) + +func (a *Attachment) SetOriginalImageMeta(width, height int, animated bool) *attachmentFileMeta { + a.imageMeta(&a.Meta.Original, width, height, animated) + return &a.Meta.Original +} + +func (a *Attachment) SetPreviewImageMeta(width, height int, animated bool) *attachmentFileMeta { + if a.Meta.Preview == nil { + a.Meta.Preview = &attachmentFileMeta{} + } + + a.imageMeta(a.Meta.Preview, width, height, animated) + return a.Meta.Preview +} + +func (a *Attachment) imageMeta(in *attachmentFileMeta, width, height int, animated bool) { + if in.Image == nil { + in.Image = &attachmentImageMeta{} + } + + if width > 0 && height > 0 { + in.Image.Animated = animated + in.Image.Width = width + in.Image.Height = height + } +} + +func (meta *attachmentMeta) Scan(value interface{}) error { + //lint:ignore S1034 This typecast is intentional, we need to get []byte out of a []uint8 + switch value.(type) { + case nil: + *meta = attachmentMeta{} + case []uint8: + if err := json.Unmarshal(value.([]byte), meta); err != nil { + return errors.Wrapf(err, "Can not scan '%v' into attachmentMeta", value) + } + } + + return nil +} + +func (meta attachmentMeta) Value() (driver.Value, error) { + return json.Marshal(meta) +}