Compare commits

..

1 Commits

Author SHA1 Message Date
snipe 1abd669de5 Refactor custom fields handling for storage
Signed-off-by: snipe <snipe@snipe.net>
2024-07-08 14:00:15 +01:00
5631 changed files with 387546 additions and 248298 deletions
+11 -1109
View File
File diff suppressed because it is too large Load Diff
+3 -13
View File
@@ -1,8 +1,6 @@
# -------------------------------------------- # --------------------------------------------
# REQUIRED: DB SETUP # REQUIRED: DB SETUP
# -------------------------------------------- # --------------------------------------------
# https://mariadb.com/kb/en/mariadb-server-docker-official-image-environment-variables/
MYSQL_DATABASE=snipeit MYSQL_DATABASE=snipeit
MYSQL_USER=snipeit MYSQL_USER=snipeit
MYSQL_PASSWORD=changeme1234 MYSQL_PASSWORD=changeme1234
@@ -11,12 +9,12 @@ MYSQL_ROOT_PASSWORD=changeme1234
# REQUIRED: BASIC APP SETTINGS # REQUIRED: BASIC APP SETTINGS
# -------------------------------------------- # --------------------------------------------
APP_ENV=develop APP_ENV=develop
APP_DEBUG=true APP_DEBUG=false
# please regenerate the APP_KEY value by calling `docker-compose run --rm snipeit bash` and then `php artisan key:generate --show` and then copy paste the value here # please regenerate the APP_KEY value by calling `docker-compose run --rm snipeit bash` and then `php artisan key:generate --show` and then copy paste the value here
APP_KEY=base64:3ilviXqB9u6DX1NRcyWGJ+sjySF+H18CPDGb3+IVwMQ= APP_KEY=base64:3ilviXqB9u6DX1NRcyWGJ+sjySF+H18CPDGb3+IVwMQ=
APP_URL=http://localhost:8000 APP_URL=http://localhost:8000
APP_TIMEZONE='UTC' APP_TIMEZONE='UTC'
APP_LOCALE=en-US APP_LOCALE=en
MAX_RESULTS=500 MAX_RESULTS=500
# -------------------------------------------- # --------------------------------------------
@@ -35,7 +33,6 @@ DB_USERNAME=snipeit
DB_PASSWORD=changeme1234 DB_PASSWORD=changeme1234
DB_PREFIX=null DB_PREFIX=null
DB_DUMP_PATH='/usr/bin' DB_DUMP_PATH='/usr/bin'
DB_DUMP_SKIP_SSL=true
DB_CHARSET=utf8mb4 DB_CHARSET=utf8mb4
DB_COLLATION=utf8mb4_unicode_ci DB_COLLATION=utf8mb4_unicode_ci
@@ -79,13 +76,6 @@ MAIL_BACKUP_NOTIFICATION_DRIVER=null
MAIL_BACKUP_NOTIFICATION_ADDRESS=null MAIL_BACKUP_NOTIFICATION_ADDRESS=null
BACKUP_ENV=true BACKUP_ENV=true
# --------------------------------------------
# OPTIONAL: CHANGE PHP UPLOAD LIMITS (UNCOMMENT WHEN NEEDING TO BE CHANGED)
# --------------------------------------------
#PHP_UPLOAD_LIMIT=10
#PHP_POST_MAX_SIZE=10
#PHP_UPLOAD_MAX_FILESIZE=10
#PHP_MEMORY_LIMIT=10
# -------------------------------------------- # --------------------------------------------
# OPTIONAL: SESSION SETTINGS # OPTIONAL: SESSION SETTINGS
@@ -166,7 +156,7 @@ RESET_PASSWORD_LINK_EXPIRES=900
# -------------------------------------------- # --------------------------------------------
# OPTIONAL: MISC # OPTIONAL: MISC
# -------------------------------------------- # --------------------------------------------
LOG_CHANNEL=single LOG_CHANNEL=stderr
LOG_MAX_DAYS=10 LOG_MAX_DAYS=10
APP_LOCKED=false APP_LOCKED=false
APP_CIPHER=AES-256-CBC APP_CIPHER=AES-256-CBC
+4 -16
View File
@@ -1,7 +1,7 @@
# -------------------------------------------- # --------------------------------------------
# REQUIRED: DOCKER SPECIFIC SETTINGS # REQUIRED: DOCKER SPECIFIC SETTINGS
# -------------------------------------------- # --------------------------------------------
APP_VERSION= APP_VERSION=v6.4.1
APP_PORT=8000 APP_PORT=8000
# -------------------------------------------- # --------------------------------------------
@@ -9,12 +9,12 @@ APP_PORT=8000
# -------------------------------------------- # --------------------------------------------
APP_ENV=production APP_ENV=production
APP_DEBUG=false APP_DEBUG=false
# Please regenerate the APP_KEY value by calling `docker compose run --rm app php artisan key:generate --show`. Copy paste the value here # Please regenerate the APP_KEY value by calling `docker compose run --rm snipeit php artisan key:generate --show`. Copy paste the value here
APP_KEY=base64:3ilviXqB9u6DX1NRcyWGJ+sjySF+H18CPDGb3+IVwMQ= APP_KEY=base64:3ilviXqB9u6DX1NRcyWGJ+sjySF+H18CPDGb3+IVwMQ=
APP_URL=http://localhost:8000 APP_URL=http://localhost:8000
# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones - TZ identifier # https://en.wikipedia.org/wiki/List_of_tz_database_time_zones - TZ identifier
APP_TIMEZONE='UTC' APP_TIMEZONE='UTC'
APP_LOCALE=en-US APP_LOCALE=en
MAX_RESULTS=500 MAX_RESULTS=500
# -------------------------------------------- # --------------------------------------------
@@ -28,7 +28,6 @@ PUBLIC_FILESYSTEM_DISK=local_public
# -------------------------------------------- # --------------------------------------------
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=db DB_HOST=db
DB_SOCKET=null
DB_PORT='3306' DB_PORT='3306'
DB_DATABASE=snipeit DB_DATABASE=snipeit
DB_USERNAME=snipeit DB_USERNAME=snipeit
@@ -36,7 +35,6 @@ DB_PASSWORD=changeme1234
MYSQL_ROOT_PASSWORD=changeme1234 MYSQL_ROOT_PASSWORD=changeme1234
DB_PREFIX=null DB_PREFIX=null
DB_DUMP_PATH='/usr/bin' DB_DUMP_PATH='/usr/bin'
DB_DUMP_SKIP_SSL=true
DB_CHARSET=utf8mb4 DB_CHARSET=utf8mb4
DB_COLLATION=utf8mb4_unicode_ci DB_COLLATION=utf8mb4_unicode_ci
@@ -85,15 +83,6 @@ MAIL_BACKUP_NOTIFICATION_DRIVER=null
MAIL_BACKUP_NOTIFICATION_ADDRESS=null MAIL_BACKUP_NOTIFICATION_ADDRESS=null
BACKUP_ENV=true BACKUP_ENV=true
# --------------------------------------------
# OPTIONAL: CHANGE PHP UPLOAD LIMITS (UNCOMMENT WHEN NEEDING TO BE CHANGED)
# --------------------------------------------
#PHP_UPLOAD_LIMIT=10
#PHP_POST_MAX_SIZE=10
#PHP_UPLOAD_MAX_FILESIZE=10
#PHP_MEMORY_LIMIT=10
# -------------------------------------------- # --------------------------------------------
# OPTIONAL: SESSION SETTINGS # OPTIONAL: SESSION SETTINGS
# -------------------------------------------- # --------------------------------------------
@@ -108,7 +97,7 @@ API_TOKEN_EXPIRATION_YEARS=40
# -------------------------------------------- # --------------------------------------------
# OPTIONAL: SECURITY HEADER SETTINGS # OPTIONAL: SECURITY HEADER SETTINGS
# -------------------------------------------- # --------------------------------------------
APP_TRUSTED_PROXIES=192.168.1.1,10.0.0.1,172.16.0.0/12 APP_TRUSTED_PROXIES=192.168.1.1,10.0.0.1,172.0.0.0/8
ALLOW_IFRAMING=false ALLOW_IFRAMING=false
REFERRER_POLICY=same-origin REFERRER_POLICY=same-origin
ENABLE_CSP=false ENABLE_CSP=false
@@ -169,7 +158,6 @@ AWS_DEFAULT_REGION=null
LOGIN_MAX_ATTEMPTS=5 LOGIN_MAX_ATTEMPTS=5
LOGIN_LOCKOUT_DURATION=60 LOGIN_LOCKOUT_DURATION=60
RESET_PASSWORD_LINK_EXPIRES=900 RESET_PASSWORD_LINK_EXPIRES=900
INVITE_PASSWORD_LINK_EXPIRES=1500
# -------------------------------------------- # --------------------------------------------
# OPTIONAL: MISC # OPTIONAL: MISC
+1 -1
View File
@@ -6,7 +6,7 @@ APP_DEBUG=false
APP_KEY=base64:hTUIUh9CP6dQx+6EjSlfWTgbaMaaRvlpEwk45vp+xmk= APP_KEY=base64:hTUIUh9CP6dQx+6EjSlfWTgbaMaaRvlpEwk45vp+xmk=
APP_URL=http://127.0.0.1:8000 APP_URL=http://127.0.0.1:8000
APP_TIMEZONE='US/Eastern' APP_TIMEZONE='US/Eastern'
APP_LOCALE=en-US APP_LOCALE=en
APP_LOCKED=false APP_LOCKED=false
MAX_RESULTS=200 MAX_RESULTS=200
+2 -22
View File
@@ -24,18 +24,14 @@ PUBLIC_FILESYSTEM_DISK=local_public
# -------------------------------------------- # --------------------------------------------
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_SOCKET=null
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=null DB_DATABASE=null
DB_USERNAME=null DB_USERNAME=null
DB_PASSWORD=null DB_PASSWORD=null
DB_PREFIX=null DB_PREFIX=null
DB_DUMP_PATH='/usr/bin' DB_DUMP_PATH='/usr/bin'
DB_DUMP_SKIP_SSL=false
DB_CHARSET=utf8mb4 DB_CHARSET=utf8mb4
DB_COLLATION=utf8mb4_unicode_ci DB_COLLATION=utf8mb4_unicode_ci
DB_SANITIZE_BY_DEFAULT=false
# -------------------------------------------- # --------------------------------------------
# OPTIONAL: SSL DATABASE SETTINGS # OPTIONAL: SSL DATABASE SETTINGS
@@ -82,12 +78,6 @@ MAIL_BACKUP_NOTIFICATION_ADDRESS=null
BACKUP_ENV=true BACKUP_ENV=true
ALLOW_BACKUP_DELETE=false ALLOW_BACKUP_DELETE=false
ALLOW_DATA_PURGE=false ALLOW_DATA_PURGE=false
ALL_BACKUP_KEEP_DAYS=7
DAILY_BACKUP_KEEP_DAYS=16
WEEKLY_BACKUP_KEEP_WEEKS=8
MONTHLY_BACKUP_KEEP_MONTHS=4
YEARLY_BACKUP_KEEP_YEARS=2
BACKUP_PURGE_OLDEST_AT_MEGS=5000
# -------------------------------------------- # --------------------------------------------
# OPTIONAL: SESSION SETTINGS # OPTIONAL: SESSION SETTINGS
@@ -97,11 +87,10 @@ SESSION_LIFETIME=12000
EXPIRE_ON_CLOSE=false EXPIRE_ON_CLOSE=false
ENCRYPT=false ENCRYPT=false
COOKIE_NAME=snipeit_session COOKIE_NAME=snipeit_session
PASSPORT_COOKIE_NAME='snipeit_passport_token'
COOKIE_DOMAIN=null COOKIE_DOMAIN=null
SECURE_COOKIES=false SECURE_COOKIES=false
API_TOKEN_EXPIRATION_YEARS=15 API_TOKEN_EXPIRATION_YEARS=15
BS_TABLE_STORAGE=localStorage BS_TABLE_STORAGE=cookieStorage
BS_TABLE_DEEPLINK=true BS_TABLE_DEEPLINK=true
# -------------------------------------------- # --------------------------------------------
@@ -175,13 +164,11 @@ LOGIN_AUTOCOMPLETE=false
RESET_PASSWORD_LINK_EXPIRES=15 RESET_PASSWORD_LINK_EXPIRES=15
PASSWORD_CONFIRM_TIMEOUT=10800 PASSWORD_CONFIRM_TIMEOUT=10800
PASSWORD_RESET_MAX_ATTEMPTS_PER_MIN=50 PASSWORD_RESET_MAX_ATTEMPTS_PER_MIN=50
INVITE_PASSWORD_LINK_EXPIRES=1500
# -------------------------------------------- # --------------------------------------------
# OPTIONAL: MISC # OPTIONAL: MISC
# -------------------------------------------- # --------------------------------------------
LOG_CHANNEL=single LOG_CHANNEL=single
LOG_DEPRECATIONS=false
LOG_MAX_DAYS=10 LOG_MAX_DAYS=10
APP_LOCKED=false APP_LOCKED=false
APP_CIPHER=AES-256-CBC APP_CIPHER=AES-256-CBC
@@ -193,16 +180,9 @@ LDAP_TIME_LIM=600
IMPORT_TIME_LIMIT=600 IMPORT_TIME_LIMIT=600
IMPORT_MEMORY_LIMIT=500M IMPORT_MEMORY_LIMIT=500M
REPORT_TIME_LIMIT=12000 REPORT_TIME_LIMIT=12000
REQUIRE_SAML=false
API_THROTTLE_PER_MINUTE=120 API_THROTTLE_PER_MINUTE=120
CSV_ESCAPE_FORMULAS=true CSV_ESCAPE_FORMULAS=true
LIVEWIRE_URL_PREFIX=null
# --------------------------------------------
# OPTIONAL: SAML SETTINGS
# --------------------------------------------
REQUIRE_SAML=false
SAML_KEY_SIZE=2048
# -------------------------------------------- # --------------------------------------------
# OPTIONAL: HASHING # OPTIONAL: HASHING
+38
View File
@@ -0,0 +1,38 @@
#### Expected Behavior (or desired behavior if a feature request)
(what you expect to happen goes here)
-----
#### Actual Behavior
(what actually happens goes here)
-----
#### Please confirm you have done the following before posting your bug report:
- [ ] I have enabled debug mode
- [ ] I have read [checked the Common Issues page](https://snipe-it.readme.io/docs/common-issues)
-----
#### Provide answers to these questions:
- Is this a fresh install or an upgrade?
- Version of Snipe-IT you're running
- Version of PHP you're running
- Version of MySQL/MariaDB you're running
- What OS and web server you're running Snipe-IT on
- What method you used to install Snipe-IT (install.sh, manual installation, docker, etc)
- WITH DEBUG TURNED ON, if you're getting an error in your browser, include that error
- What specific Snipe-IT page you're on, and what specific element you're interacting with to trigger the error
- If a stacktrace is provided in the error, include that too.
- Any errors that appear in your browser's error console.
- Confirm whether the error is reproducible on the demo: https://snipeitapp.com/demo.
- Include any additional information you can find in `storage/logs` and your webserver's logs.
- Include what you've done so far in the installation, and if you got any error messages along the way.
- Indicate whether or not you've manually edited any data directly in the database
Please do not post an issue without answering the related questions above. If you have opened a different issue and already answered these questions, answer them again, once for every ticket. It will be next to impossible for us to help you.
https://snipe-it.readme.io/docs/getting-help
+129
View File
@@ -0,0 +1,129 @@
name: Bug Report
description: Create a report to help us improve
body:
- type: checkboxes
attributes:
label: Debug mode
description: Please confirm you have done the following before posting your bug report
options:
- label: I have enabled debug mode
required: true
- label: I have read [checked the Common Issues page](https://snipe-it.readme.io/docs/common-issues)
required: true
- type: textarea
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
attributes:
label: Reproduction steps
description: Steps to reproduce the behavior.
value: |
1.
2.
3.
...
validations:
required: true
- type: textarea
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
attributes:
label: Screenshots
description: 'If applicable, add screenshots to help explain your problem.'
- type: markdown
attributes:
value: "### Server"
- type: input
attributes:
label: Snipe-IT Version
validations:
required: true
- type: input
id: server_operatingSystem
attributes:
label: Operating System
description: 'e.g. Ubuntu, Windows'
validations:
required: true
- type: input
attributes:
label: Web Server
description: 'e.g. Apache, IIS'
validations:
required: true
- type: input
attributes:
label: PHP Version
validations:
required: true
- type: markdown
attributes:
value: "### Desktop"
- type: input
id: desktop_operatingSystem
attributes:
label: Operating System
description: 'e.g. Ubuntu, Windows'
- type: input
id: desktop_browser
attributes:
label: Browser
description: 'e.g. Google Chrome, Safari'
- type: input
id: desktop_version
attributes:
label: Version
description: 'e.g. 93'
- type: markdown
attributes:
value: "### Mobile"
- type: input
attributes:
label: Device
description: 'e.g. iPhone 6, Pixel 4a'
- type: input
id: mobile_operatingSystem
attributes:
label: Operating System
description: 'e.g. iOS 8.1, Android 9'
- type: input
id: mobile_browser
attributes:
label: Browser
description: 'e.g. Google Chrome, Safari'
- type: input
id: mobile_version
attributes:
label: Version
description: 'e.g. 93'
- type: textarea
attributes:
label: Error messages
description: |
WITH DEBUG TURNED ON, if you're getting an error in your browser, include that error
If a stacktrace is provided in the error, include that too.
Any errors that appear in your browser's error console.
Confirm whether the error is reproducible on the demo: https://snipeitapp.com/demo.
Include any additional information you can find in `storage/logs` and your webserver's logs.
Include the output from `php -m` (this should display what modules you have enabled.)
render: shell
- type: textarea
attributes:
label: Additional context
description: |
Is this a fresh install or an upgrade?
What OS and web server you're running Snipe-IT on
What method you used to install Snipe-IT (install.sh, manual installation, docker, etc)
Include what you've done so far in the installation, and if you got any error messages along the way.
Indicate whether or not you've manually edited any data directly in the database
Add any other context about the problem here.
- type: markdown
attributes:
value: Please do not post an issue without answering the related questions above. If you have opened a different issue and already answered these questions, answer them again, once for every ticket. It will be next to impossible for us to help you.
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
@@ -0,0 +1,25 @@
name: Feature Request
description: Suggest an idea for this project
title: "[Feature Request]: "
labels: ["feature request"]
body:
- type: textarea
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is. The more information you can provide about your use-case, the more liklely we are to consider your feature.
validations:
required: true
- type: textarea
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
- type: textarea
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
+40
View File
@@ -0,0 +1,40 @@
# Description
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context, providing screenshots where practical. List any dependencies that are required for this change.
Fixes # (issue)
## Type of change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
# How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration
- [ ] Test A
- [ ] Test B
**Test Configuration**:
* PHP version:
* MySQL version
* Webserver version
* OS version
# Checklist:
- [ ] I have read the Contributing documentation available here: https://snipe-it.readme.io/docs/contributing-overview
- [ ] I have formatted this PR according to the project guidelines: https://snipe-it.readme.io/docs/contributing-overview#pull-request-guidelines
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
+43
View File
@@ -0,0 +1,43 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 60
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- pinned
- security
- :woman_technologist: ready for dev
- :moneybag: bounty
- :hand: bug
- "🔐 security"
- "👩‍💻 ready for dev"
- "💰 bounty"
- "✋ bug"
exemptMilestones: true
# Label to use when marking an issue as stale
staleLabel: stale
only: issues
# Comment to post when removing the stale label.
unmarkComment: >
Okay, it looks like this issue or feature request might still be important. We'll re-open
it for now. Thank you for letting us know!
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
Is this still relevant? We haven't heard from anyone in a bit. If so,
please comment with any updates or additional detail.
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Don't
take it personally, we just need to keep a handle on things. Thank you
for your contributions!
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: >
This issue has been automatically closed because it has not had
recent activity. If you believe this is still an issue, please confirm that
this issue is still happening in the most recent version of Snipe-IT and reply
to this thread to re-open it.
+1
View File
@@ -0,0 +1 @@
memory_limit= 2048M
+7
View File
@@ -0,0 +1,7 @@
# Configuration for weekly-digest - https://github.com/apps/weekly-digest
publishDay: sun
canPublishIssues: true
canPublishPullRequests: true
canPublishContributors: true
canPublishStargazers: true
canPublishCommits: true
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
language: [ 'javascript' ] language: [ 'javascript' ]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
+6 -6
View File
@@ -10,10 +10,10 @@ name: Codacy Security Scan
on: on:
push: push:
branches: [ develop ] branches: [ master ]
pull_request: pull_request:
# The branches below must be a subset of the branches above # The branches below must be a subset of the branches above
branches: [ develop ] branches: [ master ]
schedule: schedule:
- cron: '36 23 * * 3' - cron: '36 23 * * 3'
@@ -22,21 +22,21 @@ permissions:
jobs: jobs:
codacy-security-scan: codacy-security-scan:
# Ensure schedule job never runs on forked repos. It's only executed for 'grokability/snipe-it' # Ensure schedule job never runs on forked repos. It's only executed for 'snipe/snipe-it'
permissions: permissions:
contents: read # for actions/checkout to fetch code contents: read # for actions/checkout to fetch code
security-events: write # for github/codeql-action/upload-sarif to upload SARIF results security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
if: (github.repository == 'grokability/snipe-it') || ((github.repository != 'grokability/snipe-it') && (github.event_name != 'schedule')) if: (github.repository == 'snipe/snipe-it') || ((github.repository != 'snipe/snipe-it') && (github.event_name != 'schedule'))
name: Codacy Security Scan name: Codacy Security Scan
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# Checkout the repository to the GitHub Actions runner # Checkout the repository to the GitHub Actions runner
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v4
# Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis
- name: Run Codacy Analysis CLI - name: Run Codacy Analysis CLI
uses: codacy/codacy-analysis-cli-action@v4.4.7 uses: codacy/codacy-analysis-cli-action@v4.4.1
with: with:
# Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository # Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository
# You can also omit the token and run the tools that support default configurations # You can also omit the token and run the tools that support default configurations
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Crowdin push - name: Crowdin push
uses: crowdin/github-action@v2 uses: crowdin/github-action@v2
+4 -4
View File
@@ -20,8 +20,8 @@ permissions:
jobs: jobs:
docker: docker:
# Ensure this job never runs on forked repos. It's only executed for 'grokability/snipe-it' # Ensure this job never runs on forked repos. It's only executed for 'snipe/snipe-it'
if: github.repository == 'grokability/snipe-it' if: github.repository == 'snipe/snipe-it'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
# Define tags to use for Docker images based on Git tags/branches (for docker/metadata-action) # Define tags to use for Docker images based on Git tags/branches (for docker/metadata-action)
@@ -32,7 +32,7 @@ jobs:
type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }},suffix=-alpine type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }},suffix=-alpine
type=ref,event=branch,enable=${{ !endsWith(github.ref, github.event.repository.default_branch) }},suffix=-alpine type=ref,event=branch,enable=${{ !endsWith(github.ref, github.event.repository.default_branch) }},suffix=-alpine
type=ref,event=tag,suffix=-alpine type=ref,event=tag,suffix=-alpine
type=semver,pattern=v{{major}}-latest-alpine type=semver,pattern=v{{major}}-latest-alpine
# Define default tag "flavor" for docker/metadata-action per # Define default tag "flavor" for docker/metadata-action per
# https://github.com/docker/metadata-action#flavor-input # https://github.com/docker/metadata-action#flavor-input
# We turn off 'latest' tag by default. # We turn off 'latest' tag by default.
@@ -42,7 +42,7 @@ jobs:
steps: steps:
# https://github.com/actions/checkout # https://github.com/actions/checkout
- name: Checkout codebase - name: Checkout codebase
uses: actions/checkout@v5 uses: actions/checkout@v4
# https://github.com/docker/setup-buildx-action # https://github.com/docker/setup-buildx-action
- name: Setup Docker Buildx - name: Setup Docker Buildx
@@ -1,5 +1,5 @@
# Snipe-IT Docker image build for hub.docker.com # Snipe-IT Docker image build for hub.docker.com
name: Docker images (Ubuntu) name: Docker images
# Run this Build for all pushes to 'master' or develop branch, or tagged releases. # Run this Build for all pushes to 'master' or develop branch, or tagged releases.
# Also run for PRs to ensure PR doesn't break Docker build process # Also run for PRs to ensure PR doesn't break Docker build process
@@ -20,8 +20,8 @@ permissions:
jobs: jobs:
docker: docker:
# Ensure this job never runs on forked repos. It's only executed for 'grokability/snipe-it' # Ensure this job never runs on forked repos. It's only executed for 'snipe/snipe-it'
if: github.repository == 'grokability/snipe-it' if: github.repository == 'snipe/snipe-it'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
# Define tags to use for Docker images based on Git tags/branches (for docker/metadata-action) # Define tags to use for Docker images based on Git tags/branches (for docker/metadata-action)
@@ -32,7 +32,7 @@ jobs:
type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }}
type=ref,event=branch,enable=${{ !endsWith(github.ref, github.event.repository.default_branch) }} type=ref,event=branch,enable=${{ !endsWith(github.ref, github.event.repository.default_branch) }}
type=ref,event=tag type=ref,event=tag
type=semver,pattern=v{{major}}-latest type=semver,pattern=v{{major}}-latest
# Define default tag "flavor" for docker/metadata-action per # Define default tag "flavor" for docker/metadata-action per
# https://github.com/docker/metadata-action#flavor-input # https://github.com/docker/metadata-action#flavor-input
# We turn off 'latest' tag by default. # We turn off 'latest' tag by default.
@@ -42,7 +42,7 @@ jobs:
steps: steps:
# https://github.com/actions/checkout # https://github.com/actions/checkout
- name: Checkout codebase - name: Checkout codebase
uses: actions/checkout@v5 uses: actions/checkout@v4
# https://github.com/docker/setup-buildx-action # https://github.com/docker/setup-buildx-action
- name: Setup Docker Buildx - name: Setup Docker Buildx
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
dockerHubDescription: dockerHubDescription:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- name: Docker Hub Description - name: Docker Hub Description
uses: grokability/dockerhub-description@7ea9d275c7cdbe2b676a093a0308c50665e3b8b4 uses: grokability/dockerhub-description@7ea9d275c7cdbe2b676a093a0308c50665e3b8b4
-40
View File
@@ -1,40 +0,0 @@
name: 'Close stale issues'
on:
schedule:
- cron: '30 1 * * *'
jobs:
stale:
runs-on: ubuntu-latest
permissions:
# contents: write # only for delete-branch option
issues: write
# pull-requests: write
steps:
- uses: actions/stale@v9
with:
debug-only: true
ascending: true
operations-per-run: 1000 # just while we're debugging
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60
days-before-close: 7
exempt-all-milestones: true
stale-issue-message: >
Is this still relevant? We haven't heard from anyone in a bit. If so,
please comment with any updates or additional detail.
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Don't
take it personally, we just need to keep a handle on things. Thank you
for your contributions!
close-issue-message: >
This issue has been automatically closed because it has not had
recent activity. If you believe this is still an issue, please confirm that
this issue is still happening in the most recent version of Snipe-IT and reply
to this thread to re-open it.
# There doesn't seem to be a 'reopen issue message'?
# Since there is no 'stale-pr-message' - PR's should not be stale'd
stale-issue-label: stale
exempt-issue-labels: >
pinned,security,:woman_technologist: ready for dev,:moneybag: bounty,:hand: bug,🔐 security,👩‍💻 ready for dev,💰 bounty,✋ bug
+4 -16
View File
@@ -25,9 +25,9 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
php-version: php-version:
- "8.1"
- "8.2" - "8.2"
- "8.3" - "8.3"
- "8.4"
name: PHP ${{ matrix.php-version }} name: PHP ${{ matrix.php-version }}
@@ -37,7 +37,7 @@ jobs:
php-version: "${{ matrix.php-version }}" php-version: "${{ matrix.php-version }}"
coverage: none coverage: none
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- name: Get Composer Cache Directory - name: Get Composer Cache Directory
id: composer-cache id: composer-cache
@@ -67,7 +67,7 @@ jobs:
run: | run: |
php artisan key:generate php artisan key:generate
php artisan migrate --force php artisan migrate --force
php artisan passport:install --no-interaction php artisan passport:install
chmod -R 777 storage bootstrap/cache chmod -R 777 storage bootstrap/cache
- name: Execute tests (Unit and Feature tests) via PHPUnit - name: Execute tests (Unit and Feature tests) via PHPUnit
@@ -76,16 +76,4 @@ jobs:
DB_DATABASE: snipeit DB_DATABASE: snipeit
DB_PORT: ${{ job.services.mysql.ports[3306] }} DB_PORT: ${{ job.services.mysql.ports[3306] }}
DB_USERNAME: root DB_USERNAME: root
LOG_CHANNEL: single run: php artisan test --parallel
LOG_LEVEL: debug
run: php artisan test
- name: Upload Laravel logs as artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: laravel-logs-php-${{ matrix.php-version }}-run-${{ github.run_attempt }}
path: |
storage/logs/*.log
if-no-files-found: ignore
retention-days: 7
+3 -18
View File
@@ -22,9 +22,6 @@ jobs:
matrix: matrix:
php-version: php-version:
- "8.2" - "8.2"
- "8.3"
- "8.4"
name: PHP ${{ matrix.php-version }} name: PHP ${{ matrix.php-version }}
@@ -34,7 +31,7 @@ jobs:
php-version: "${{ matrix.php-version }}" php-version: "${{ matrix.php-version }}"
coverage: none coverage: none
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- name: Get Composer Cache Directory - name: Get Composer Cache Directory
id: composer-cache id: composer-cache
@@ -65,7 +62,7 @@ jobs:
run: | run: |
php artisan key:generate php artisan key:generate
php artisan migrate --force php artisan migrate --force
php artisan passport:install --no-interaction php artisan passport:install
chmod -R 777 storage bootstrap/cache chmod -R 777 storage bootstrap/cache
- name: Execute tests (Unit and Feature tests) via PHPUnit - name: Execute tests (Unit and Feature tests) via PHPUnit
@@ -75,16 +72,4 @@ jobs:
DB_PORT: ${{ job.services.postgresql.ports[5432] }} DB_PORT: ${{ job.services.postgresql.ports[5432] }}
DB_USERNAME: snipeit DB_USERNAME: snipeit
DB_PASSWORD: password DB_PASSWORD: password
LOG_CHANNEL: single run: php artisan test --parallel
LOG_LEVEL: debug
run: php artisan test
- name: Upload Laravel logs as artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: laravel-logs-php-${{ matrix.php-version }}-run-${{ github.run_attempt }}
path: |
storage/logs/*.log
if-no-files-found: ignore
retention-days: 7
+4 -19
View File
@@ -15,7 +15,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
php-version: php-version:
- "8.3" - "8.1.1"
name: PHP ${{ matrix.php-version }} name: PHP ${{ matrix.php-version }}
@@ -25,7 +25,7 @@ jobs:
php-version: "${{ matrix.php-version }}" php-version: "${{ matrix.php-version }}"
coverage: none coverage: none
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- name: Get Composer Cache Directory - name: Get Composer Cache Directory
id: composer-cache id: composer-cache
@@ -43,9 +43,6 @@ jobs:
cp -v .env.testing.example .env cp -v .env.testing.example .env
cp -v .env.testing.example .env.testing cp -v .env.testing.example .env.testing
- name: Create database file
run: touch database/database.sqlite
- name: Install Dependencies - name: Install Dependencies
run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
@@ -60,17 +57,5 @@ jobs:
- name: Execute tests (Unit and Feature tests) via PHPUnit - name: Execute tests (Unit and Feature tests) via PHPUnit
env: env:
DB_CONNECTION: sqlite DB_CONNECTION: sqlite_testing
LOG_CHANNEL: single run: php artisan test --parallel
LOG_LEVEL: debug
run: php artisan test
- name: Upload Laravel logs as artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: laravel-logs-php-${{ matrix.php-version }}-run-${{ github.run_attempt }}
path: |
storage/logs/*.log
if-no-files-found: ignore
retention-days: 7
-4
View File
@@ -47,7 +47,6 @@ storage/private_uploads/users/*
tests/_data/scenarios tests/_data/scenarios
tests/_output/* tests/_output/*
tests/_support/_generated/* tests/_support/_generated/*
tests/coverage/*
/npm-debug.log /npm-debug.log
/storage/oauth-private.key /storage/oauth-private.key
/storage/oauth-public.key /storage/oauth-public.key
@@ -68,6 +67,3 @@ _ide_helper_models.php
/.phplint-cache /.phplint-cache
storage/ldap_client_tls.cert storage/ldap_client_tls.cert
storage/ldap_client_tls.key storage/ldap_client_tls.key
/storage/framework/testing
/.phpunit.cache
-240
View File
@@ -1,240 +0,0 @@
{
"standard": "WCAG2AA",
"level": "error",
"defaults": {
"useIncognitoBrowserContext": false,
"timeout": 500000,
"wait": 5000,
"ignore" : [
"WCAG2AA.Principle1.Guideline1_4.1_4_3.G145.Fail",
"WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail"
],
"viewport": {
"width": 1280,
"height": 1024
}
},
"urls": [
{
"__NOTE" : "this should always be FIRST (if browser context is preserved)",
"url": "https://snipe-it.test/login",
"actions": [
"navigate to https://snipe-it.test/login",
"screen capture tests/pa11y/login.png",
"set field input[name='username'] to admin",
"set field input[name='password'] to password",
"click element button[type=submit]",
"wait for url to be https://snipe-it.test/",
"screen capture tests/pa11y/dashboard.png"
]
},
{
"url" : "https://snipe-it.test/admin",
"actions" : [
"navigate to https://snipe-it.test/admin",
"screen capture tests/pa11y/admin-settings.png"
]
},
{
"url" : "https://snipe-it.test/admin/branding",
"actions" : [
"navigate to https://snipe-it.test/admin/branding",
"screen capture tests/pa11y/admin-branding.png"
]
},
{
"url" : "https://snipe-it.test/admin/general",
"actions" : [
"navigate to https://snipe-it.test/admin/general",
"screen capture tests/pa11y/admin-general.png"
]
},
{
"url" : "https://snipe-it.test/hardware/create",
"actions" : [
"navigate to https://snipe-it.test/hardware/create",
"screen capture tests/pa11y/asset-create.png"
]
},
{
"url" : "https://snipe-it.test/hardware",
"actions" : [
"navigate to https://snipe-it.test/hardware",
"screen capture tests/pa11y/asset-list.png"
]
},
{
"url" : "https://snipe-it.test/hardware/1",
"actions" : [
"navigate to https://snipe-it.test/hardware/1",
"screen capture tests/pa11y/asset-detail.png"
]
},
{
"url" : "https://snipe-it.test/account/view-assets",
"actions" : [
"navigate to https://snipe-it.test/account/view-assets",
"screen capture tests/pa11y/profile.png"
]
},
{
"url" : "https://snipe-it.test/licences",
"actions" : [
"navigate to https://snipe-it.test/licenses",
"screen capture tests/pa11y/license-list.png"
]
},
{
"url" : "https://snipe-it.test/licences/create",
"actions" : [
"navigate to https://snipe-it.test/licenses/create",
"screen capture tests/pa11y/license-create.png"
]
},
{
"url" : "https://snipe-it.test/licences/1",
"actions" : [
"navigate to https://snipe-it.test/licenses/1",
"screen capture tests/pa11y/license-view.png"
]
},
{
"url" : "https://snipe-it.test/consumables",
"actions" : [
"navigate to https://snipe-it.test/consumables",
"screen capture tests/pa11y/consumable-list.png"
]
},
{
"url" : "https://snipe-it.test/consumables/create",
"actions" : [
"navigate to https://snipe-it.test/consumables/create",
"screen capture tests/pa11y/consumable-create.png"
]
},
{
"url" : "https://snipe-it.test/consumables/1",
"actions" : [
"navigate to https://snipe-it.test/consumables/1",
"screen capture tests/pa11y/consumable-view.png"
]
},
{
"url" : "https://snipe-it.test/accessories",
"actions" : [
"navigate to https://snipe-it.test/accessories",
"screen capture tests/pa11y/accessory-list.png"
]
},
{
"url" : "https://snipe-it.test/accessories/create",
"actions" : [
"navigate to https://snipe-it.test/accessories/create",
"screen capture tests/pa11y/accessory-create.png"
]
},
{
"url" : "https://snipe-it.test/accessories/1",
"actions" : [
"navigate to https://snipe-it.test/accessories/1",
"screen capture tests/pa11y/accessory-view.png"
]
},
{
"url" : "https://snipe-it.test/locations",
"actions" : [
"navigate to https://snipe-it.test/locations",
"screen capture tests/pa11y/location-list.png"
]
},
{
"url" : "https://snipe-it.test/locations/create",
"actions" : [
"navigate to https://snipe-it.test/locations/create",
"screen capture tests/pa11y/location-create.png"
]
},
{
"url" : "https://snipe-it.test/locations/1",
"actions" : [
"navigate to https://snipe-it.test/locations/1",
"screen capture tests/pa11y/location-view.png"
]
},
{
"url" : "https://snipe-it.test/models",
"actions" : [
"navigate to https://snipe-it.test/models",
"screen capture tests/pa11y/model-list.png"
]
},
{
"url" : "https://snipe-it.test/models/create",
"actions" : [
"navigate to https://snipe-it.test/models/create",
"screen capture tests/pa11y/model-create.png"
]
},
{
"url" : "https://snipe-it.test/models/1",
"actions" : [
"navigate to https://snipe-it.test/models/1",
"screen capture tests/pa11y/model-view.png"
]
},
{
"url" : "https://snipe-it.test/companies",
"actions" : [
"navigate to https://snipe-it.test/companies",
"screen capture tests/pa11y/company-list.png"
]
},
{
"url" : "https://snipe-it.test/companies/create",
"actions" : [
"navigate to https://snipe-it.test/companies/create",
"screen capture tests/pa11y/company-create.png"
]
},
{
"url" : "https://snipe-it.test/companies/1",
"actions" : [
"navigate to https://snipe-it.test/companies/1",
"screen capture tests/pa11y/company-view.png"
]
},
{
"url" : "https://snipe-it.test/departments",
"actions" : [
"navigate to https://snipe-it.test/departments",
"screen capture tests/pa11y/department-list.png"
]
},
{
"url" : "https://snipe-it.test/departments/create",
"actions" : [
"navigate to https://snipe-it.test/departments/create",
"screen capture tests/pa11y/department-create.png"
]
},
{
"url" : "https://snipe-it.test/departments/1",
"actions" : [
"navigate to https://snipe-it.test/departments/1",
"screen capture tests/pa11y/department-view.png"
]
},
{
"url" : "https://snipe-it.test/invalid-url",
"actions" : [
"navigate to https://snipe-it.test/invalid-url",
"screen capture tests/pa11y/404.png"
]
}
]
}
+4 -4
View File
@@ -3,8 +3,8 @@
"DOC2": "In other words, what you see locally are the requirements for your _current_ install", "DOC2": "In other words, what you see locally are the requirements for your _current_ install",
"DOC3": "Please don't rely on these versions for planning upgrades unless you've fetched the most recent version", "DOC3": "Please don't rely on these versions for planning upgrades unless you've fetched the most recent version",
"DOC4": "You should really just ignore it and run upgrade.php. Really", "DOC4": "You should really just ignore it and run upgrade.php. Really",
"php_min_version": "8.2.0", "php_min_version": "8.1.0",
"php_max_major_minor": "8.4", "php_max_major_minor": "8.3",
"php_max_wontwork": "8.5.0", "php_max_wontwork": "8.4.0",
"current_snipeit_version": "8.0" "current_snipeit_version": "7.0"
} }
+455 -68
View File
@@ -1,74 +1,461 @@
Thanks goes to all of these wonderful people ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)) who have helped Snipe-IT get this far: Thanks goes to all of these wonderful people ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)) who have helped Snipe-IT get this far:
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section --> <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
| [<img src="https://avatars3.githubusercontent.com/u/197404?v=3" width="110px;"/><br /><sub>snipe</sub>](http://www.snipe.net)<br />[💻](https://github.com/snipe/snipe-it/commits?author=snipe "Code") [🚇](#infra-snipe "Infrastructure (Hosting, Build-Tools, etc)") [📖](https://github.com/snipe/snipe-it/commits?author=snipe "Documentation") [⚠️](https://github.com/snipe/snipe-it/commits?author=snipe "Tests") [🐛](https://github.com/snipe/snipe-it/issues?q=author%3Asnipe "Bug reports") [🎨](#design-snipe "Design") [👀](#review-snipe "Reviewed Pull Requests") | [<img src="https://avatars0.githubusercontent.com/u/36335?v=3" width="110px;"/><br /><sub>Brady Wetherington</sub>](http://www.uberbrady.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=uberbrady "Code") [📖](https://github.com/snipe/snipe-it/commits?author=uberbrady "Documentation") [🚇](#infra-uberbrady "Infrastructure (Hosting, Build-Tools, etc)") [👀](#review-uberbrady "Reviewed Pull Requests") | [<img src="https://avatars0.githubusercontent.com/u/3803132?v=3" width="110px;"/><br /><sub>Daniel Meltzer</sub>](https://github.com/dmeltzer)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dmeltzer "Code") [⚠️](https://github.com/snipe/snipe-it/commits?author=dmeltzer "Tests") [📖](https://github.com/snipe/snipe-it/commits?author=dmeltzer "Documentation") | [<img src="https://avatars0.githubusercontent.com/u/1609106?v=3" width="110px;"/><br /><sub>Michael T</sub>](http://www.tuckertechonline.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mtucker6784 "Code") | [<img src="https://avatars2.githubusercontent.com/u/3274937?v=3" width="110px;"/><br /><sub>madd15</sub>](https://github.com/madd15)<br />[📖](https://github.com/snipe/snipe-it/commits?author=madd15 "Documentation") [💬](#question-madd15 "Answering Questions") | [<img src="https://avatars2.githubusercontent.com/u/894126?v=3" width="110px;"/><br /><sub>Vincent Sposato</sub>](https://github.com/vsposato)<br />[💻](https://github.com/snipe/snipe-it/commits?author=vsposato "Code") | [<img src="https://avatars0.githubusercontent.com/u/1639757?v=3" width="110px;"/><br /><sub>Andrea Bergamasco</sub>](https://github.com/vjandrea)<br />[💻](https://github.com/snipe/snipe-it/commits?author=vjandrea "Code") | <!-- prettier-ignore-start -->
| :---: | :---: | :---: | :---: | :---: | :---: | :---: | <!-- markdownlint-disable -->
| [<img src="https://avatars0.githubusercontent.com/u/10640152?v=3" width="110px;"/><br /><sub>Karol</sub>](https://github.com/kpawelski)<br />[🌍](#translation-kpawelski "Translation") [💻](https://github.com/snipe/snipe-it/commits?author=kpawelski "Code") | [<img src="https://avatars3.githubusercontent.com/u/600106?v=3" width="110px;"/><br /><sub>morph027</sub>](http://blog.morph027.de/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=morph027 "Code") | [<img src="https://avatars3.githubusercontent.com/u/22935755?v=3" width="110px;"/><br /><sub>fvleminckx</sub>](https://github.com/fvleminckx)<br />[🚇](#infra-fvleminckx "Infrastructure (Hosting, Build-Tools, etc)") | [<img src="https://avatars2.githubusercontent.com/u/15633547?v=3" width="110px;"/><br /><sub>itsupportcmsukorg</sub>](https://github.com/itsupportcmsukorg)<br />[💻](https://github.com/snipe/snipe-it/commits?author=itsupportcmsukorg "Code") [🐛](https://github.com/snipe/snipe-it/issues?q=author%3Aitsupportcmsukorg "Bug reports") | [<img src="https://avatars3.githubusercontent.com/u/12373799?v=3" width="110px;"/><br /><sub>Frank</sub>](https://override.io)<br />[💻](https://github.com/snipe/snipe-it/commits?author=base-zero "Code") | [<img src="https://avatars0.githubusercontent.com/u/10137?v=3" width="110px;"/><br /><sub>Deleted user</sub>](https://github.com/ghost)<br />[🌍](#translation-ghost "Translation") [💻](https://github.com/snipe/snipe-it/commits?author=ghost "Code") | [<img src="https://avatars1.githubusercontent.com/u/10802313?v=3" width="110px;"/><br /><sub>tiagom62</sub>](https://github.com/tiagom62)<br />[💻](https://github.com/snipe/snipe-it/commits?author=tiagom62 "Code") [🚇](#infra-tiagom62 "Infrastructure (Hosting, Build-Tools, etc)") | <table>
| [<img src="https://avatars3.githubusercontent.com/u/2389047?v=3" width="110px;"/><br /><sub>Ryan Stafford</sub>](https://github.com/rystaf)<br />[💻](https://github.com/snipe/snipe-it/commits?author=rystaf "Code") | [<img src="https://avatars2.githubusercontent.com/u/10345935?v=3" width="110px;"/><br /><sub>Eammon Hanlon</sub>](https://github.com/ehanlon)<br />[💻](https://github.com/snipe/snipe-it/commits?author=ehanlon "Code") | [<img src="https://avatars0.githubusercontent.com/u/441924?v=3" width="110px;"/><br /><sub>zjean</sub>](https://github.com/zjean)<br />[💻](https://github.com/snipe/snipe-it/commits?author=zjean "Code") | [<img src="https://avatars0.githubusercontent.com/u/12660103?v=3" width="110px;"/><br /><sub>Matthias Frei</sub>](http://www.frei.media)<br />[💻](https://github.com/snipe/snipe-it/commits?author=FREImedia "Code") | [<img src="https://avatars0.githubusercontent.com/u/3767518?v=3" width="110px;"/><br /><sub>opsydev</sub>](https://github.com/opsydev)<br />[💻](https://github.com/snipe/snipe-it/commits?author=opsydev "Code") | [<img src="https://avatars1.githubusercontent.com/u/82290?v=3" width="110px;"/><br /><sub>Daniel Dreier</sub>](http://www.ddreier.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=ddreier "Code") | [<img src="https://avatars0.githubusercontent.com/u/23448?v=3" width="110px;"/><br /><sub>Nikolai Prokoschenko</sub>](http://rassie.org)<br />[💻](https://github.com/snipe/snipe-it/commits?author=rassie "Code") | <tbody>
| [<img src="https://avatars0.githubusercontent.com/u/13452757?v=3" width="110px;"/><br /><sub>Drew</sub>](https://github.com/YetAnotherCodeMonkey)<br />[💻](https://github.com/snipe/snipe-it/commits?author=YetAnotherCodeMonkey "Code") | [<img src="https://avatars0.githubusercontent.com/u/1342320?v=3" width="110px;"/><br /><sub>Walter</sub>](https://github.com/merid14)<br />[💻](https://github.com/snipe/snipe-it/commits?author=merid14 "Code") | [<img src="https://avatars3.githubusercontent.com/u/11254614?v=3" width="110px;"/><br /><sub>Petr Baloun</sub>](https://github.com/balous)<br />[💻](https://github.com/snipe/snipe-it/commits?author=balous "Code") | [<img src="https://avatars0.githubusercontent.com/u/6117660?v=3" width="110px;"/><br /><sub>reidblomquist</sub>](https://github.com/reidblomquist)<br />[📖](https://github.com/snipe/snipe-it/commits?author=reidblomquist "Documentation") | [<img src="https://avatars0.githubusercontent.com/u/539914?v=3" width="110px;"/><br /><sub>Mathieu Kooiman</sub>](https://github.com/mathieuk)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mathieuk "Code") | [<img src="https://avatars3.githubusercontent.com/u/6606421?v=3" width="110px;"/><br /><sub>csayre</sub>](https://github.com/csayre)<br />[📖](https://github.com/snipe/snipe-it/commits?author=csayre "Documentation") | [<img src="https://avatars1.githubusercontent.com/u/768488?v=3" width="110px;"/><br /><sub>Adam Dunson</sub>](https://github.com/adamdunson)<br />[💻](https://github.com/snipe/snipe-it/commits?author=adamdunson "Code") | <tr>
| [<img src="https://avatars0.githubusercontent.com/u/5547470?v=3" width="110px;"/><br /><sub>Hereward</sub>](https://github.com/thehereward)<br />[💻](https://github.com/snipe/snipe-it/commits?author=thehereward "Code") | [<img src="https://avatars0.githubusercontent.com/u/5802977?v=3" width="110px;"/><br /><sub>swoopdk</sub>](https://github.com/swoopdk)<br />[💻](https://github.com/snipe/snipe-it/commits?author=swoopdk "Code") | [<img src="https://avatars1.githubusercontent.com/u/3470403?v=3" width="110px;"/><br /><sub>Abdullah Alansari</sub>](https://linkedin.com/in/ahimta)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Ahimta "Code") | [<img src="https://avatars0.githubusercontent.com/u/796443?v=3" width="110px;"/><br /><sub>Micael Rodrigues</sub>](https://github.com/MicaelRodrigues)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MicaelRodrigues "Code") | [<img src="https://avatars0.githubusercontent.com/u/614564?v=3" width="110px;"/><br /><sub>Patrick Gallagher</sub>](http://macadmincorner.com)<br />[📖](https://github.com/snipe/snipe-it/commits?author=patgmac "Documentation") | [<img src="https://avatars3.githubusercontent.com/u/7165922?v=3" width="110px;"/><br /><sub>Miliamber</sub>](https://github.com/Miliamber)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Miliamber "Code") | [<img src="https://avatars3.githubusercontent.com/u/861766?v=3" width="110px;"/><br /><sub>hawk554</sub>](https://github.com/hawk554)<br />[💻](https://github.com/snipe/snipe-it/commits?author=hawk554 "Code") | <td align="center" valign="top" width="14.28%"><a href="http://www.snipe.net"><img src="https://avatars3.githubusercontent.com/u/197404?v=3?s=110" width="110px;" alt="snipe"/><br /><sub><b>snipe</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=snipe" title="Code">💻</a> <a href="#infra-snipe" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/snipe/snipe-it/commits?author=snipe" title="Documentation">📖</a> <a href="https://github.com/snipe/snipe-it/commits?author=snipe" title="Tests">⚠️</a> <a href="https://github.com/snipe/snipe-it/issues?q=author%3Asnipe" title="Bug reports">🐛</a> <a href="#design-snipe" title="Design">🎨</a> <a href="https://github.com/snipe/snipe-it/pulls?q=is%3Apr+reviewed-by%3Asnipe" title="Reviewed Pull Requests">👀</a></td>
| [<img src="https://avatars1.githubusercontent.com/u/1695622?v=3" width="110px;"/><br /><sub>Justin Kerr</sub>](http://jbirdkerr.net)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jbirdkerr "Code") | [<img src="https://avatars3.githubusercontent.com/u/11426176?v=3" width="110px;"/><br /><sub>Ira W. Snyder</sub>](http://www.irasnyder.com/devel/)<br />[📖](https://github.com/snipe/snipe-it/commits?author=irasnyd "Documentation") | [<img src="https://avatars2.githubusercontent.com/u/2475759?v=3" width="110px;"/><br /><sub>Aladin Alaily</sub>](https://github.com/aalaily)<br />[💻](https://github.com/snipe/snipe-it/commits?author=aalaily "Code") | [<img src="https://avatars0.githubusercontent.com/u/10247644?v=3" width="110px;"/><br /><sub>Chase Hansen</sub>](https://github.com/kobie-chasehansen)<br />[💻](https://github.com/snipe/snipe-it/commits?author=kobie-chasehansen "Code") [💬](#question-kobie-chasehansen "Answering Questions") [🐛](https://github.com/snipe/snipe-it/issues?q=author%3Akobie-chasehansen "Bug reports") | [<img src="https://avatars2.githubusercontent.com/u/13545400?v=3" width="110px;"/><br /><sub>IDM Helpdesk</sub>](https://github.com/IDM-Helpdesk)<br />[💻](https://github.com/snipe/snipe-it/commits?author=IDM-Helpdesk "Code") | [<img src="https://avatars2.githubusercontent.com/u/614439?v=3" width="110px;"/><br /><sub>Kai</sub>](http://balticer.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=balticer "Code") | [<img src="https://avatars1.githubusercontent.com/u/8762511?v=3" width="110px;"/><br /><sub>Michael Daniels</sub>](http://www.michaeldaniels.me)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mdaniels5757 "Code") | <td align="center" valign="top" width="14.28%"><a href="http://www.uberbrady.com"><img src="https://avatars0.githubusercontent.com/u/36335?v=3?s=110" width="110px;" alt="Brady Wetherington"/><br /><sub><b>Brady Wetherington</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=uberbrady" title="Code">💻</a> <a href="https://github.com/snipe/snipe-it/commits?author=uberbrady" title="Documentation">📖</a> <a href="#infra-uberbrady" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/snipe/snipe-it/pulls?q=is%3Apr+reviewed-by%3Auberbrady" title="Reviewed Pull Requests">👀</a></td>
| [<img src="https://avatars3.githubusercontent.com/u/1532660?v=3" width="110px;"/><br /><sub>Tom Castleman</sub>](http://tomcastleman.me)<br />[💻](https://github.com/snipe/snipe-it/commits?author=tomcastleman "Code") | [<img src="https://avatars3.githubusercontent.com/u/10723243?v=3" width="110px;"/><br /><sub>Daniel Nemanic</sub>](https://github.com/DanielNemanic)<br />[💻](https://github.com/snipe/snipe-it/commits?author=DanielNemanic "Code") | [<img src="https://avatars0.githubusercontent.com/u/150648?v=3" width="110px;"/><br /><sub>SouthWolf</sub>](https://github.com/southwolf)<br />[💻](https://github.com/snipe/snipe-it/commits?author=southwolf "Code") | [<img src="https://avatars2.githubusercontent.com/u/131616?v=3" width="110px;"/><br /><sub>Ivar Nesje</sub>](https://github.com/ivarne)<br />[💻](https://github.com/snipe/snipe-it/commits?author=ivarne "Code") | [<img src="https://avatars1.githubusercontent.com/u/62333?v=3" width="110px;"/><br /><sub>Jérémy Benoist</sub>](http://www.j0k3r.net)<br />[📖](https://github.com/snipe/snipe-it/commits?author=j0k3r "Documentation") | [<img src="https://avatars2.githubusercontent.com/u/724344?v=3" width="110px;"/><br /><sub>Chris Leathley</sub>](https://github.com/cleathley)<br />[🚇](#infra-cleathley "Infrastructure (Hosting, Build-Tools, etc)") | [<img src="https://avatars0.githubusercontent.com/u/972498?v=3" width="110px;"/><br /><sub>splaer</sub>](https://github.com/splaer)<br />[🐛](https://github.com/snipe/snipe-it/issues?q=author%3Asplaer "Bug reports") [💻](https://github.com/snipe/snipe-it/commits?author=splaer "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/dmeltzer"><img src="https://avatars0.githubusercontent.com/u/3803132?v=3?s=110" width="110px;" alt="Daniel Meltzer"/><br /><sub><b>Daniel Meltzer</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=dmeltzer" title="Code">💻</a> <a href="https://github.com/snipe/snipe-it/commits?author=dmeltzer" title="Tests">⚠️</a> <a href="https://github.com/snipe/snipe-it/commits?author=dmeltzer" title="Documentation">📖</a></td>
| [<img src="https://avatars1.githubusercontent.com/u/967362?v=3" width="110px;"/><br /><sub>Joe Ferguson</sub>](http://www.joeferguson.me)<br />[💻](https://github.com/snipe/snipe-it/commits?author=svpernova09 "Code") | [<img src="https://avatars3.githubusercontent.com/u/6108682?v=3" width="110px;"/><br /><sub>diwanicki</sub>](https://github.com/diwanicki)<br />[💻](https://github.com/snipe/snipe-it/commits?author=diwanicki "Code") [📖](https://github.com/snipe/snipe-it/commits?author=diwanicki "Documentation") | [<img src="https://avatars3.githubusercontent.com/u/2527115?v=3" width="110px;"/><br /><sub>Lee Thoong Ching</sub>](https://github.com/pakkua80)<br />[📖](https://github.com/snipe/snipe-it/commits?author=pakkua80 "Documentation") [💻](https://github.com/snipe/snipe-it/commits?author=pakkua80 "Code") | [<img src="https://avatars1.githubusercontent.com/u/461491?v=3" width="110px;"/><br /><sub>Marek Šuppa</sub>](http://shu.io)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mrshu "Code") | [<img src="https://avatars1.githubusercontent.com/u/8693762?v=3" width="110px;"/><br /><sub>Juan J. Martinez</sub>](https://github.com/mizar1616)<br />[🌍](#translation-mizar1616 "Translation") | [<img src="https://avatars1.githubusercontent.com/u/1458388?v=3" width="110px;"/><br /><sub>R Ryan Dial</sub>](https://github.com/rrdial)<br />[🌍](#translation-rrdial "Translation") | [<img src="https://avatars2.githubusercontent.com/u/2871745?v=3" width="110px;"/><br /><sub>Andrej Manduch</sub>](https://github.com/burlito)<br />[📖](https://github.com/snipe/snipe-it/commits?author=burlito "Documentation") | <td align="center" valign="top" width="14.28%"><a href="http://www.tuckertechonline.com"><img src="https://avatars0.githubusercontent.com/u/1609106?v=3?s=110" width="110px;" alt="Michael T"/><br /><sub><b>Michael T</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=mtucker6784" title="Code">💻</a></td>
| [<img src="https://avatars0.githubusercontent.com/u/8341172?v=3" width="110px;"/><br /><sub>Jay Richards</sub>](http://www.cordeos.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=technogenus "Code") | [<img src="https://avatars2.githubusercontent.com/u/7295127?v=3" width="110px;"/><br /><sub>Alexander Innes</sub>](https://necurity.co.uk)<br />[💻](https://github.com/snipe/snipe-it/commits?author=leostat "Code") | [<img src="https://avatars2.githubusercontent.com/u/334485?v=3" width="110px;"/><br /><sub>Danny Garcia</sub>](https://buzzedword.codes)<br />[💻](https://github.com/snipe/snipe-it/commits?author=buzzedword "Code") | [<img src="https://avatars2.githubusercontent.com/u/366855?v=3" width="110px;"/><br /><sub>archpoint</sub>](https://github.com/archpoint)<br />[💻](https://github.com/snipe/snipe-it/commits?author=archpoint "Code") | [<img src="https://avatars1.githubusercontent.com/u/67991?v=3" width="110px;"/><br /><sub>Jake McGraw</sub>](http://www.jakemcgraw.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jakemcgraw "Code") | [<img src="https://avatars1.githubusercontent.com/u/1714374?v=3" width="110px;"/><br /><sub>FleischKarussel</sub>](https://github.com/FleischKarussel)<br />[📖](https://github.com/snipe/snipe-it/commits?author=FleischKarussel "Documentation") | [<img src="https://avatars3.githubusercontent.com/u/319644?v=3" width="110px;"/><br /><sub>Dylan Yi</sub>](https://github.com/feeva)<br />[💻](https://github.com/snipe/snipe-it/commits?author=feeva "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/madd15"><img src="https://avatars2.githubusercontent.com/u/3274937?v=3?s=110" width="110px;" alt="madd15"/><br /><sub><b>madd15</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=madd15" title="Documentation">📖</a> <a href="#question-madd15" title="Answering Questions">💬</a></td>
| [<img src="https://avatars2.githubusercontent.com/u/857740?v=3" width="110px;"/><br /><sub>Gil Rutkowski</sub>](http://FlashingCursor.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=flashingcursor "Code") | [<img src="https://avatars3.githubusercontent.com/u/129360?v=3" width="110px;"/><br /><sub>Desmond Morris</sub>](http://www.desmondmorris.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=desmondmorris "Code") | [<img src="https://avatars2.githubusercontent.com/u/52936?v=3" width="110px;"/><br /><sub>Nick Peelman</sub>](http://peelman.us)<br />[💻](https://github.com/snipe/snipe-it/commits?author=peelman "Code") | [<img src="https://avatars0.githubusercontent.com/u/53161?v=3" width="110px;"/><br /><sub>Abraham Vegh</sub>](https://abrahamvegh.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=abrahamvegh "Code") | [<img src="https://avatars0.githubusercontent.com/u/2818680?v=3" width="110px;"/><br /><sub>Mohamed Rashid</sub>](https://github.com/rashivkp)<br />[📖](https://github.com/snipe/snipe-it/commits?author=rashivkp "Documentation") | [<img src="https://avatars3.githubusercontent.com/u/1509456?v=3" width="110px;"/><br /><sub>Kasey</sub>](http://hinchk.github.io)<br />[💻](https://github.com/snipe/snipe-it/commits?author=HinchK "Code") | [<img src="https://avatars2.githubusercontent.com/u/10522541?v=3" width="110px;"/><br /><sub>Brett</sub>](https://github.com/BrettFagerlund)<br />[⚠️](https://github.com/snipe/snipe-it/commits?author=BrettFagerlund "Tests") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/vsposato"><img src="https://avatars2.githubusercontent.com/u/894126?v=3?s=110" width="110px;" alt="Vincent Sposato"/><br /><sub><b>Vincent Sposato</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=vsposato" title="Code">💻</a></td>
| [<img src="https://avatars2.githubusercontent.com/u/16108587?v=3" width="110px;"/><br /><sub>Jason Spriggs</sub>](http://jasonspriggs.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jasonspriggs "Code") | [<img src="https://avatars2.githubusercontent.com/u/1134568?v=3" width="110px;"/><br /><sub>Nate Felton</sub>](http://n8felton.wordpress.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=n8felton "Code") | [<img src="https://avatars2.githubusercontent.com/u/14036694?v=3" width="110px;"/><br /><sub>Manasses Ferreira</sub>](http://homepages.dcc.ufmg.br/~manassesferreira)<br />[💻](https://github.com/snipe/snipe-it/commits?author=manassesferreira "Code") | [<img src="https://avatars0.githubusercontent.com/u/15913949?v=3" width="110px;"/><br /><sub>Steve</sub>](https://github.com/steveelwood)<br />[⚠️](https://github.com/snipe/snipe-it/commits?author=steveelwood "Tests") | [<img src="https://avatars1.githubusercontent.com/u/3361683?v=3" width="110px;"/><br /><sub>matc</sub>](http://twitter.com/matc)<br />[⚠️](https://github.com/snipe/snipe-it/commits?author=matc "Tests") | [<img src="https://avatars3.githubusercontent.com/u/7405702?v=3" width="110px;"/><br /><sub>Cole R. Davis</sub>](http://www.davisracingteam.com)<br />[⚠️](https://github.com/snipe/snipe-it/commits?author=VanillaNinjaD "Tests") | [<img src="https://avatars2.githubusercontent.com/u/10167681?v=3" width="110px;"/><br /><sub>gibsonjoshua55</sub>](https://github.com/gibsonjoshua55)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gibsonjoshua55 "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/vjandrea"><img src="https://avatars0.githubusercontent.com/u/1639757?v=3?s=110" width="110px;" alt="Andrea Bergamasco"/><br /><sub><b>Andrea Bergamasco</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=vjandrea" title="Code">💻</a></td>
| [<img src="https://avatars2.githubusercontent.com/u/2809241?v=4" width="110px;"/><br /><sub>Robin Temme</sub>](https://github.com/zwerch)<br />[💻](https://github.com/snipe/snipe-it/commits?author=zwerch "Code") | [<img src="https://avatars0.githubusercontent.com/u/6961695?v=4" width="110px;"/><br /><sub>Iman</sub>](https://github.com/imanghafoori1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=imanghafoori1 "Code") | [<img src="https://avatars1.githubusercontent.com/u/6551003?v=4" width="110px;"/><br /><sub>Richard Hofman</sub>](https://github.com/richardhofman6)<br />[💻](https://github.com/snipe/snipe-it/commits?author=richardhofman6 "Code") | [<img src="https://avatars0.githubusercontent.com/u/3697569?v=4" width="110px;"/><br /><sub>gizzmojr</sub>](https://github.com/gizzmojr)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gizzmojr "Code") | [<img src="https://avatars3.githubusercontent.com/u/404729?v=4" width="110px;"/><br /><sub>Jenny Li</sub>](https://github.com/imjennyli)<br />[📖](https://github.com/snipe/snipe-it/commits?author=imjennyli "Documentation") | [<img src="https://avatars0.githubusercontent.com/u/869227?v=4" width="110px;"/><br /><sub>Geoff Young</sub>](https://github.com/GeoffYoung)<br />[💻](https://github.com/snipe/snipe-it/commits?author=GeoffYoung "Code") | [<img src="https://avatars3.githubusercontent.com/u/1068477?v=4" width="110px;"/><br /><sub>Elliot Blackburn</sub>](http://www.elliotblackburn.com)<br />[📖](https://github.com/snipe/snipe-it/commits?author=BlueHatbRit "Documentation") | </tr>
| [<img src="https://avatars1.githubusercontent.com/u/6357451?v=4" width="110px;"/><br /><sub>Tõnis Ormisson</sub>](http://andmemasin.eu)<br />[💻](https://github.com/snipe/snipe-it/commits?author=TonisOrmisson "Code") | [<img src="https://avatars0.githubusercontent.com/u/449411?v=4" width="110px;"/><br /><sub>Nicolai Essig</sub>](http://www.nicolai-essig.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=thakilla "Code") | [<img src="https://avatars1.githubusercontent.com/u/14809698?v=4" width="110px;"/><br /><sub>Danielle</sub>](https://github.com/techincolor)<br />[📖](https://github.com/snipe/snipe-it/commits?author=techincolor "Documentation") | [<img src="https://avatars1.githubusercontent.com/u/18545156?v=4" width="110px;"/><br /><sub>Lawrence</sub>](https://github.com/TheVakman)<br />[⚠️](https://github.com/snipe/snipe-it/commits?author=TheVakman "Tests") [🐛](https://github.com/snipe/snipe-it/issues?q=author%3ATheVakman "Bug reports") | [<img src="https://avatars1.githubusercontent.com/u/22473767?v=4" width="110px;"/><br /><sub>uknzaeinozpas</sub>](https://github.com/uknzaeinozpas)<br />[⚠️](https://github.com/snipe/snipe-it/commits?author=uknzaeinozpas "Tests") [💻](https://github.com/snipe/snipe-it/commits?author=uknzaeinozpas "Code") | [<img src="https://avatars3.githubusercontent.com/u/422752?v=4" width="110px;"/><br /><sub>Ryan</sub>](https://github.com/Gelob)<br />[📖](https://github.com/snipe/snipe-it/commits?author=Gelob "Documentation") | [<img src="https://avatars1.githubusercontent.com/u/10672546?v=4" width="110px;"/><br /><sub>vcordes79</sub>](https://github.com/vcordes79)<br />[💻](https://github.com/snipe/snipe-it/commits?author=vcordes79 "Code") | <tr>
| [<img src="https://avatars3.githubusercontent.com/u/27958330?v=4" width="110px;"/><br /><sub>fordster78</sub>](https://github.com/fordster78)<br />[💻](https://github.com/snipe/snipe-it/commits?author=fordster78 "Code") | [<img src="https://avatars0.githubusercontent.com/u/34064225?v=4" width="110px;"/><br /><sub>CronKz</sub>](https://github.com/CronKz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=CronKz "Code") [🌍](#translation-CronKz "Translation") | [<img src="https://avatars1.githubusercontent.com/u/585486?v=4" width="110px;"/><br /><sub>Tim Bishop</sub>](https://github.com/tdb)<br />[💻](https://github.com/snipe/snipe-it/commits?author=tdb "Code") | [<img src="https://avatars2.githubusercontent.com/u/5384694?v=4" width="110px;"/><br /><sub>Sean McIlvenna</sub>](https://www.seanmcilvenna.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=seanmcilvenna "Code") | [<img src="https://avatars3.githubusercontent.com/u/36515590?v=4" width="110px;"/><br /><sub>cepacs</sub>](https://github.com/cepacs)<br />[🐛](https://github.com/snipe/snipe-it/issues?q=author%3Acepacs "Bug reports") [📖](https://github.com/snipe/snipe-it/commits?author=cepacs "Documentation") | [<img src="https://avatars2.githubusercontent.com/u/37537300?v=4" width="110px;"/><br /><sub>lea-mink</sub>](https://github.com/lea-mink)<br />[💻](https://github.com/snipe/snipe-it/commits?author=lea-mink "Code") | [<img src="https://avatars0.githubusercontent.com/u/7140719?v=4" width="110px;"/><br /><sub>Hannah Tinkler</sub>](https://github.com/hannahtinkler)<br />[💻](https://github.com/snipe/snipe-it/commits?author=hannahtinkler "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/kpawelski"><img src="https://avatars0.githubusercontent.com/u/10640152?v=3?s=110" width="110px;" alt="Karol"/><br /><sub><b>Karol</b></sub></a><br /><a href="#translation-kpawelski" title="Translation">🌍</a> <a href="https://github.com/snipe/snipe-it/commits?author=kpawelski" title="Code">💻</a></td>
| [<img src="https://avatars1.githubusercontent.com/u/1086388?v=4" width="110px;"/><br /><sub>Doeke Zanstra</sub>](https://github.com/doekman)<br />[💻](https://github.com/snipe/snipe-it/commits?author=doekman "Code") | [<img src="https://avatars1.githubusercontent.com/u/4325936?v=4" width="110px;"/><br /><sub>Djamon Staal</sub>](https://www.sdhd.nl/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=SjamonDaal "Code") | [<img src="https://avatars3.githubusercontent.com/u/12306859?v=4" width="110px;"/><br /><sub>Earl Ramirez</sub>](https://github.com/EarlRamirez)<br />[💻](https://github.com/snipe/snipe-it/commits?author=EarlRamirez "Code") | [<img src="https://avatars2.githubusercontent.com/u/8671456?v=4" width="110px;"/><br /><sub>Richard Ray Thomas</sub>](https://github.com/RichardRay)<br />[💻](https://github.com/snipe/snipe-it/commits?author=RichardRay "Code") | [<img src="https://avatars3.githubusercontent.com/u/1852688?v=4" width="110px;"/><br /><sub>Ryan Kuba</sub>](https://www.taisun.io/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=thelamer "Code") | [<img src="https://avatars1.githubusercontent.com/u/6751928?v=4" width="110px;"/><br /><sub>Brian Monroe</sub>](https://github.com/ParadoxGuitarist)<br />[💻](https://github.com/snipe/snipe-it/commits?author=ParadoxGuitarist "Code") | [<img src="https://avatars1.githubusercontent.com/u/605167?v=4" width="110px;"/><br /><sub>plexorama</sub>](https://github.com/plexorama)<br />[💻](https://github.com/snipe/snipe-it/commits?author=plexorama "Code") | <td align="center" valign="top" width="14.28%"><a href="http://blog.morph027.de/"><img src="https://avatars3.githubusercontent.com/u/600106?v=3?s=110" width="110px;" alt="morph027"/><br /><sub><b>morph027</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=morph027" title="Code">💻</a></td>
| [<img src="https://avatars2.githubusercontent.com/u/1795149?v=4" width="110px;"/><br /><sub>Till Deeke</sub>](https://tilldeeke.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=tilldeeke "Code") | [<img src="https://avatars0.githubusercontent.com/u/12634129?v=4" width="110px;"/><br /><sub>5quirrel</sub>](https://github.com/5quirrel)<br />[💻](https://github.com/snipe/snipe-it/commits?author=5quirrel "Code") | [<img src="https://avatars1.githubusercontent.com/u/13071957?v=4" width="110px;"/><br /><sub>Jason</sub>](https://github.com/jasonlshelton)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jasonlshelton "Code") | [<img src="https://avatars3.githubusercontent.com/u/7128321?v=4" width="110px;"/><br /><sub>Antti</sub>](https://github.com/chemfy)<br />[💻](https://github.com/snipe/snipe-it/commits?author=chemfy "Code") | [<img src="https://avatars3.githubusercontent.com/u/10080364?v=4" width="110px;"/><br /><sub>DeusMaximus</sub>](https://github.com/DeusMaximus)<br />[💻](https://github.com/snipe/snipe-it/commits?author=DeusMaximus "Code") | [<img src="https://avatars2.githubusercontent.com/u/16384611?v=4" width="110px;"/><br /><sub>a-royal</sub>](https://github.com/A-ROYAL)<br />[🌍](#translation-A-ROYAL "Translation") | [<img src="https://avatars0.githubusercontent.com/u/5358208?v=4" width="110px;"/><br /><sub>Alberto Aldrigo</sub>](https://github.com/albertoaldrigo)<br />[🌍](#translation-albertoaldrigo "Translation") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/fvleminckx"><img src="https://avatars3.githubusercontent.com/u/22935755?v=3?s=110" width="110px;" alt="fvleminckx"/><br /><sub><b>fvleminckx</b></sub></a><br /><a href="#infra-fvleminckx" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
| [<img src="https://avatars0.githubusercontent.com/u/1412342?v=4" width="110px;"/><br /><sub>Alex Stanev</sub>](http://alex.stanev.org/blog)<br />[🌍](#translation-RealEnder "Translation") | [<img src="https://avatars0.githubusercontent.com/u/177295?v=4" width="110px;"/><br /><sub>Andreas Rehm</sub>](http://devel.itsolution2.de)<br />[🌍](#translation-sirrus "Translation") | [<img src="https://avatars0.githubusercontent.com/u/5080535?v=4" width="110px;"/><br /><sub>Andreas Erhard</sub>](https://github.com/xelan)<br />[🌍](#translation-xelan "Translation") | [<img src="https://avatars2.githubusercontent.com/u/142350?v=4" width="110px;"/><br /><sub>Andrés Vanegas Jiménez</sub>](https://github.com/angeldeejay)<br />[🌍](#translation-angeldeejay "Translation") | [<img src="https://avatars0.githubusercontent.com/u/3910403?v=4" width="110px;"/><br /><sub>Antonio Schiavon</sub>](https://github.com/aschiavon91)<br />[🌍](#translation-aschiavon91 "Translation") | [<img src="https://avatars0.githubusercontent.com/u/10464547?v=4" width="110px;"/><br /><sub>benunter</sub>](https://github.com/benunter)<br />[🌍](#translation-benunter "Translation") | [<img src="https://avatars1.githubusercontent.com/u/5038647?v=4" width="110px;"/><br /><sub>Borys Żmuda</sub>](http://catweb24.pl)<br />[🌍](#translation-rudashi "Translation") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/itsupportcmsukorg"><img src="https://avatars2.githubusercontent.com/u/15633547?v=3?s=110" width="110px;" alt="itsupportcmsukorg"/><br /><sub><b>itsupportcmsukorg</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=itsupportcmsukorg" title="Code">💻</a> <a href="https://github.com/snipe/snipe-it/issues?q=author%3Aitsupportcmsukorg" title="Bug reports">🐛</a></td>
| [<img src="https://avatars0.githubusercontent.com/u/5539359?v=4" width="110px;"/><br /><sub>chibacityblues</sub>](https://github.com/chibacityblues)<br />[🌍](#translation-chibacityblues "Translation") | [<img src="https://avatars1.githubusercontent.com/u/1954830?v=4" width="110px;"/><br /><sub>Chien Wei Lin</sub>](https://github.com/cwlin0416)<br />[🌍](#translation-cwlin0416 "Translation") | [<img src="https://avatars3.githubusercontent.com/u/11700533?v=4" width="110px;"/><br /><sub>Christian Schuster</sub>](https://github.com/Againstreality)<br />[🌍](#translation-Againstreality "Translation") | [<img src="https://avatars1.githubusercontent.com/u/4308704?v=4" width="110px;"/><br /><sub>Christian Stefanus</sub>](http://chriss.webhostid.com)<br />[🌍](#translation-kopi-item "Translation") | [<img src="https://avatars3.githubusercontent.com/u/3009327?v=4" width="110px;"/><br /><sub>wxcafé</sub>](http://wxcafe.net)<br />[🌍](#translation-wxcafe "Translation") | [<img src="https://avatars3.githubusercontent.com/u/35761525?v=4" width="110px;"/><br /><sub>dpyroc</sub>](https://github.com/dpyroc)<br />[🌍](#translation-dpyroc "Translation") | [<img src="https://avatars1.githubusercontent.com/u/2153639?v=4" width="110px;"/><br /><sub>Daniel Friedlmaier</sub>](http://www.friedlmaier.net)<br />[🌍](#translation-da-friedl "Translation") | <td align="center" valign="top" width="14.28%"><a href="https://override.io"><img src="https://avatars3.githubusercontent.com/u/12373799?v=3?s=110" width="110px;" alt="Frank"/><br /><sub><b>Frank</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=base-zero" title="Code">💻</a></td>
| [<img src="https://avatars1.githubusercontent.com/u/2947640?v=4" width="110px;"/><br /><sub>Daniel Heene</sub>](https://github.com/danielheene)<br />[🌍](#translation-danielheene "Translation") | [<img src="https://avatars3.githubusercontent.com/u/319022?v=4" width="110px;"/><br /><sub>danielcb</sub>](https://github.com/danielcb)<br />[🌍](#translation-danielcb "Translation") | [<img src="https://avatars3.githubusercontent.com/u/15846537?v=4" width="110px;"/><br /><sub>Dominik Senti</sub>](https://github.com/dominiksenti)<br />[🌍](#translation-dominiksenti "Translation") | [<img src="https://avatars0.githubusercontent.com/u/25570954?v=4" width="110px;"/><br /><sub>Eric Gautheron</sub>](http://www.konectik.com)<br />[🌍](#translation-EpixFr "Translation") | [<img src="https://avatars1.githubusercontent.com/u/5732623?v=4" width="110px;"/><br /><sub>Erlend Pilø</sub>](https://erlpil.com)<br />[🌍](#translation-Erlpil "Translation") | [<img src="https://avatars0.githubusercontent.com/u/541832?v=4" width="110px;"/><br /><sub>Fabio Rapposelli</sub>](http://fabio.technology)<br />[🌍](#translation-frapposelli "Translation") | [<img src="https://avatars2.githubusercontent.com/u/3605240?v=4" width="110px;"/><br /><sub>Felipe Barros</sub>](https://github.com/fgbs)<br />[🌍](#translation-fgbs "Translation") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/ghost"><img src="https://avatars0.githubusercontent.com/u/10137?v=3?s=110" width="110px;" alt="Deleted user"/><br /><sub><b>Deleted user</b></sub></a><br /><a href="#translation-ghost" title="Translation">🌍</a> <a href="https://github.com/snipe/snipe-it/commits?author=ghost" title="Code">💻</a></td>
| [<img src="https://avatars0.githubusercontent.com/u/257745?v=4" width="110px;"/><br /><sub>Fernando Possebon</sub>](https://github.com/possebon)<br />[🌍](#translation-possebon "Translation") | [<img src="https://avatars3.githubusercontent.com/u/2540832?v=4" width="110px;"/><br /><sub>gdraque</sub>](https://github.com/gdraque)<br />[🌍](#translation-gdraque "Translation") | [<img src="https://avatars0.githubusercontent.com/u/23440381?v=4" width="110px;"/><br /><sub>Georg Wallisch</sub>](https://github.com/georgwallisch)<br />[🌍](#translation-georgwallisch "Translation") | [<img src="https://avatars1.githubusercontent.com/u/9852832?v=4" width="110px;"/><br /><sub>Gerardo Robles</sub>](https://github.com/jgroblesr85)<br />[🌍](#translation-jgroblesr85 "Translation") | [<img src="https://avatars2.githubusercontent.com/u/11082640?v=4" width="110px;"/><br /><sub>Gluek</sub>](https://t.me/Gluek)<br />[🌍](#translation-mrgluek "Translation") | [<img src="https://avatars0.githubusercontent.com/u/6847946?v=4" width="110px;"/><br /><sub>AdnanAbuShahad</sub>](https://github.com/AdnanAbuShahad)<br />[🌍](#translation-AdnanAbuShahad "Translation") | [<img src="https://avatars1.githubusercontent.com/u/3580608?v=4" width="110px;"/><br /><sub>Hafidzi My</sub>](https://hafidzi.my)<br />[🌍](#translation-hafidzi "Translation") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/tiagom62"><img src="https://avatars1.githubusercontent.com/u/10802313?v=3?s=110" width="110px;" alt="tiagom62"/><br /><sub><b>tiagom62</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=tiagom62" title="Code">💻</a> <a href="#infra-tiagom62" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
| [<img src="https://avatars2.githubusercontent.com/u/205521?v=4" width="110px;"/><br /><sub>Harim Park</sub>](https://github.com/fofwisdom)<br />[🌍](#translation-fofwisdom "Translation") | [<img src="https://avatars2.githubusercontent.com/u/3333841?v=4" width="110px;"/><br /><sub>Henrik Kentsson</sub>](http://www.kentsson.se)<br />[🌍](#translation-Kentsson "Translation") | [<img src="https://avatars0.githubusercontent.com/u/36551034?v=4" width="110px;"/><br /><sub>Husnul Yaqien</sub>](https://github.com/husnulyaqien)<br />[🌍](#translation-husnulyaqien "Translation") | [<img src="https://avatars1.githubusercontent.com/u/2372747?v=4" width="110px;"/><br /><sub>Ibrahim</sub>](http://abaalkhail.org)<br />[🌍](#translation-abaalkh "Translation") | [<img src="https://avatars0.githubusercontent.com/u/1389334?v=4" width="110px;"/><br /><sub>igolman</sub>](https://github.com/igolman)<br />[🌍](#translation-igolman "Translation") | [<img src="https://avatars1.githubusercontent.com/u/3257070?v=4" width="110px;"/><br /><sub>itangiang</sub>](https://github.com/itangiang)<br />[🌍](#translation-itangiang "Translation") | [<img src="https://avatars2.githubusercontent.com/u/14814254?v=4" width="110px;"/><br /><sub>jarby1211</sub>](https://github.com/jarby1211)<br />[🌍](#translation-jarby1211 "Translation") | </tr>
| [<img src="https://avatars3.githubusercontent.com/u/6719357?v=4" width="110px;"/><br /><sub>Jhonn Willker</sub>](http://jwillker.com)<br />[🌍](#translation-JohnWillker "Translation") | [<img src="https://avatars2.githubusercontent.com/u/10983635?v=4" width="110px;"/><br /><sub>Jose</sub>](https://github.com/joxelito94)<br />[🌍](#translation-joxelito94 "Translation") | [<img src="https://avatars0.githubusercontent.com/u/5206122?v=4" width="110px;"/><br /><sub>laopangzi</sub>](https://github.com/laopangzi)<br />[🌍](#translation-laopangzi "Translation") | [<img src="https://avatars2.githubusercontent.com/u/79707?v=4" width="110px;"/><br /><sub>Lars Strojny</sub>](http://usrportage.de)<br />[🌍](#translation-lstrojny "Translation") | [<img src="https://avatars0.githubusercontent.com/u/389801?v=4" width="110px;"/><br /><sub>MarcosBL</sub>](http://twitter.com/marcosbl)<br />[🌍](#translation-MarcosBL "Translation") | [<img src="https://avatars3.githubusercontent.com/u/35664606?v=4" width="110px;"/><br /><sub>marie joy cajes</sub>](https://github.com/mariejoyacajes)<br />[🌍](#translation-mariejoyacajes "Translation") | [<img src="https://avatars2.githubusercontent.com/u/3052816?v=4" width="110px;"/><br /><sub>Mark S. Johansen</sub>](http://www.markjohansen.dk)<br />[🌍](#translation-msjohansen "Translation") | <tr>
| [<img src="https://avatars2.githubusercontent.com/u/982885?v=4" width="110px;"/><br /><sub>Martin Stub</sub>](http://martinstub.dk)<br />[🌍](#translation-stubben "Translation") | [<img src="https://avatars2.githubusercontent.com/u/28959963?v=4" width="110px;"/><br /><sub>Meyer Flavio</sub>](https://github.com/meyerf99)<br />[🌍](#translation-meyerf99 "Translation") | [<img src="https://avatars3.githubusercontent.com/u/796443?v=4" width="110px;"/><br /><sub>Micael Rodrigues</sub>](https://github.com/MicaelRodrigues)<br />[🌍](#translation-MicaelRodrigues "Translation") | [<img src="https://avatars0.githubusercontent.com/u/10481331?v=4" width="110px;"/><br /><sub>Mikael Rasmussen</sub>](http://rubixy.com/)<br />[🌍](#translation-mikaelssen "Translation") | [<img src="https://avatars1.githubusercontent.com/u/1544552?v=4" width="110px;"/><br /><sub>IxFail</sub>](https://github.com/IxFail)<br />[🌍](#translation-IxFail "Translation") | [<img src="https://avatars3.githubusercontent.com/u/18483118?v=4" width="110px;"/><br /><sub>Mohammed Fota</sub>](http://www.mohammedfota.com)<br />[🌍](#translation-MohammedFota "Translation") | [<img src="https://avatars0.githubusercontent.com/u/227080?v=4" width="110px;"/><br /><sub>Moayad Alserihi</sub>](https://github.com/omego)<br />[🌍](#translation-omego "Translation") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/rystaf"><img src="https://avatars3.githubusercontent.com/u/2389047?v=3?s=110" width="110px;" alt="Ryan Stafford"/><br /><sub><b>Ryan Stafford</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=rystaf" title="Code">💻</a></td>
| [<img src="https://avatars0.githubusercontent.com/u/1680266?v=4" width="110px;"/><br /><sub>saymd</sub>](https://github.com/saymd)<br />[🌍](#translation-saymd "Translation") | [<img src="https://avatars0.githubusercontent.com/u/1826808?v=4" width="110px;"/><br /><sub>Patrik Larsson</sub>](https://nordsken.se)<br />[🌍](#translation-pooot "Translation") | [<img src="https://avatars1.githubusercontent.com/u/20584746?v=4" width="110px;"/><br /><sub>drcryo</sub>](https://github.com/drcryo)<br />[🌍](#translation-drcryo "Translation") | [<img src="https://avatars1.githubusercontent.com/u/19408004?v=4" width="110px;"/><br /><sub>pawel1615</sub>](https://github.com/pawel1615)<br />[🌍](#translation-pawel1615 "Translation") | [<img src="https://avatars2.githubusercontent.com/u/23340468?v=4" width="110px;"/><br /><sub>bodrovics</sub>](https://github.com/bodrovics)<br />[🌍](#translation-bodrovics "Translation") | [<img src="https://avatars0.githubusercontent.com/u/3257654?v=4" width="110px;"/><br /><sub>priatna</sub>](https://github.com/priatna)<br />[🌍](#translation-priatna "Translation") | [<img src="https://avatars1.githubusercontent.com/u/5358374?v=4" width="110px;"/><br /><sub>Fan Jiang</sub>](https://amayume.net)<br />[🌍](#translation-ProfFan "Translation") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/ehanlon"><img src="https://avatars2.githubusercontent.com/u/10345935?v=3?s=110" width="110px;" alt="Eammon Hanlon"/><br /><sub><b>Eammon Hanlon</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=ehanlon" title="Code">💻</a></td>
| [<img src="https://avatars1.githubusercontent.com/u/22555451?v=4" width="110px;"/><br /><sub>ragnarcx</sub>](https://github.com/ragnarcx)<br />[🌍](#translation-ragnarcx "Translation") | [<img src="https://avatars2.githubusercontent.com/u/18654582?v=4" width="110px;"/><br /><sub>Rein van Haaren</sub>](http://www.reinvanhaaren.nl/)<br />[🌍](#translation-reinvanhaaren "Translation") | [<img src="https://avatars1.githubusercontent.com/u/386672?v=4" width="110px;"/><br /><sub>Teguh Dwicaksana</sub>](http://dheche.songolimo.net)<br />[🌍](#translation-dheche "Translation") | [<img src="https://avatars2.githubusercontent.com/u/2572552?v=4" width="110px;"/><br /><sub>fraccie</sub>](https://github.com/FRaccie)<br />[🌍](#translation-FRaccie "Translation") | [<img src="https://avatars0.githubusercontent.com/u/35182720?v=4" width="110px;"/><br /><sub>vinzruzell</sub>](https://github.com/vinzruzell)<br />[🌍](#translation-vinzruzell "Translation") | [<img src="https://avatars1.githubusercontent.com/u/7883603?v=4" width="110px;"/><br /><sub>Kevin Austin</sub>](http://kevinaustin.com)<br />[🌍](#translation-vipsystem "Translation") | [<img src="https://avatars3.githubusercontent.com/u/3861828?v=4" width="110px;"/><br /><sub>Wira Sandy</sub>](http://azuraweb.xyz)<br />[🌍](#translation-wira-sandy "Translation") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/zjean"><img src="https://avatars0.githubusercontent.com/u/441924?v=3?s=110" width="110px;" alt="zjean"/><br /><sub><b>zjean</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=zjean" title="Code">💻</a></td>
| [<img src="https://avatars2.githubusercontent.com/u/8663789?v=4" width="110px;"/><br /><sub>Илья</sub>](https://github.com/GrayHoax)<br />[🌍](#translation-GrayHoax "Translation") | [<img src="https://avatars3.githubusercontent.com/u/30119111?v=4" width="110px;"/><br /><sub>GodUseVPN</sub>](https://github.com/godusevpn)<br />[🌍](#translation-godusevpn "Translation") | [<img src="https://avatars1.githubusercontent.com/u/745576?v=4" width="110px;"/><br /><sub>周周</sub>](https://github.com/EngrZhou)<br />[🌍](#translation-EngrZhou "Translation") | [<img src="https://avatars3.githubusercontent.com/u/1631095?v=4" width="110px;"/><br /><sub>Sam</sub>](https://github.com/takuy)<br />[💻](https://github.com/snipe/snipe-it/commits?author=takuy "Code") | [<img src="https://avatars1.githubusercontent.com/u/264022?v=4" width="110px;"/><br /><sub>Azerothian</sub>](https://www.illisian.com.au)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Azerothian "Code") | [<img src="https://avatars1.githubusercontent.com/u/4930051?v=4" width="110px;"/><br /><sub>Wes Hulette</sub>](http://macfoo.wordpress.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jwhulette "Code") | [<img src="https://avatars0.githubusercontent.com/u/8134591?v=4" width="110px;"/><br /><sub>patrict</sub>](https://github.com/patrict)<br />[💻](https://github.com/snipe/snipe-it/commits?author=patrict "Code") | <td align="center" valign="top" width="14.28%"><a href="http://www.frei.media"><img src="https://avatars0.githubusercontent.com/u/12660103?v=3?s=110" width="110px;" alt="Matthias Frei"/><br /><sub><b>Matthias Frei</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=FREImedia" title="Code">💻</a></td>
| [<img src="https://avatars3.githubusercontent.com/u/2611616?v=4" width="110px;"/><br /><sub>Dmitriy Minaev</sub>](https://github.com/VELIKII-DIVAN)<br />[💻](https://github.com/snipe/snipe-it/commits?author=VELIKII-DIVAN "Code") | [<img src="https://avatars0.githubusercontent.com/u/5132245?v=4" width="110px;"/><br /><sub>liquidhorse</sub>](https://github.com/liquidhorse)<br />[💻](https://github.com/snipe/snipe-it/commits?author=liquidhorse "Code") | [<img src="https://avatars1.githubusercontent.com/u/183678?v=4" width="110px;"/><br /><sub>Jordi Boggiano</sub>](https://seld.be/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Seldaek "Code") | [<img src="https://avatars0.githubusercontent.com/u/653557?v=4" width="110px;"/><br /><sub>Ivan Nieto</sub>](https://github.com/inietov)<br />[💻](https://github.com/snipe/snipe-it/commits?author=inietov "Code") | [<img src="https://avatars2.githubusercontent.com/u/6764151?v=4" width="110px;"/><br /><sub>Ben RUBSON</sub>](https://github.com/benrubson)<br />[💻](https://github.com/snipe/snipe-it/commits?author=benrubson "Code") | [<img src="https://avatars2.githubusercontent.com/u/8554558?v=4" width="110px;"/><br /><sub>NMathar</sub>](https://github.com/NMathar)<br />[💻](https://github.com/snipe/snipe-it/commits?author=NMathar "Code") | [<img src="https://avatars1.githubusercontent.com/u/139566?v=4" width="110px;"/><br /><sub>Steffen</sub>](https://github.com/smb)<br />[💻](https://github.com/snipe/snipe-it/commits?author=smb "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/opsydev"><img src="https://avatars0.githubusercontent.com/u/3767518?v=3?s=110" width="110px;" alt="opsydev"/><br /><sub><b>opsydev</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=opsydev" title="Code">💻</a></td>
| [<img src="https://avatars0.githubusercontent.com/u/6609453?v=4" width="110px;"/><br /><sub>Sxderp</sub>](https://github.com/Sxderp)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Sxderp "Code") | [<img src="https://avatars1.githubusercontent.com/u/4807843?v=4" width="110px;"/><br /><sub>fanta8897</sub>](https://github.com/fanta8897)<br />[💻](https://github.com/snipe/snipe-it/commits?author=fanta8897 "Code") | [<img src="https://avatars2.githubusercontent.com/u/2576509?v=4" width="110px;"/><br /><sub>Andrey Bolonin</sub>](https://andreybolonin.com/phpconsulting/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=andreybolonin "Code") | [<img src="https://avatars3.githubusercontent.com/u/2173307?v=4" width="110px;"/><br /><sub>shinayoshi</sub>](http://www.shinayoshi.net/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=shinayoshi "Code") | [<img src="https://avatars3.githubusercontent.com/u/2130159?v=4" width="110px;"/><br /><sub>Hubert</sub>](https://github.com/reuser)<br />[💻](https://github.com/snipe/snipe-it/commits?author=reuser "Code") | [<img src="https://avatars0.githubusercontent.com/u/6865789?v=4" width="110px;"/><br /><sub>KeenRivals</sub>](https://brashear.me)<br />[💻](https://github.com/snipe/snipe-it/commits?author=KeenRivals "Code") | [<img src="https://avatars3.githubusercontent.com/u/2902513?v=4" width="110px;"/><br /><sub>omyno</sub>](https://github.com/omyno)<br />[💻](https://github.com/snipe/snipe-it/commits?author=omyno "Code") | <td align="center" valign="top" width="14.28%"><a href="http://www.ddreier.com"><img src="https://avatars1.githubusercontent.com/u/82290?v=3?s=110" width="110px;" alt="Daniel Dreier"/><br /><sub><b>Daniel Dreier</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=ddreier" title="Code">💻</a></td>
| [<img src="https://avatars1.githubusercontent.com/u/6271335?v=4" width="110px;"/><br /><sub>Evgeny</sub>](https://github.com/jackka)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jackka "Code") | [<img src="https://avatars2.githubusercontent.com/u/1169963?v=4" width="110px;"/><br /><sub>Colin Campbell</sub>](https://digitalist.se)<br />[💻](https://github.com/snipe/snipe-it/commits?author=colin-campbell "Code") | [<img src="https://avatars3.githubusercontent.com/u/2872098?v=4" width="110px;"/><br /><sub>Ľubomír Kučera</sub>](https://github.com/lubo)<br />[💻](https://github.com/snipe/snipe-it/commits?author=lubo "Code") | [<img src="https://avatars3.githubusercontent.com/u/570639?v=4" width="110px;"/><br /><sub>Martin Meredith</sub>](https://www.sourceguru.net)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Mezzle "Code") | [<img src="https://avatars1.githubusercontent.com/u/7632599?v=4" width="110px;"/><br /><sub>Tim Farmer</sub>](https://github.com/timothyfarmer)<br />[💻](https://github.com/snipe/snipe-it/commits?author=timothyfarmer "Code") | [<img src="https://avatars0.githubusercontent.com/u/17459600?v=4" width="110px;"/><br /><sub>Marián Skrip</sub>](https://github.com/mskrip)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mskrip "Code") | [<img src="https://avatars2.githubusercontent.com/u/47435081?v=4" width="110px;"/><br /><sub>Godfrey Martinez</sub>](https://github.com/Godmartinz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Godmartinz "Code") | <td align="center" valign="top" width="14.28%"><a href="http://rassie.org"><img src="https://avatars0.githubusercontent.com/u/23448?v=3?s=110" width="110px;" alt="Nikolai Prokoschenko"/><br /><sub><b>Nikolai Prokoschenko</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=rassie" title="Code">💻</a></td>
| [<img src="https://avatars1.githubusercontent.com/u/2075128?v=4" width="110px;"/><br /><sub>bigtreeEdo</sub>](https://github.com/bigtreeEdo)<br />[💻](https://github.com/snipe/snipe-it/commits?author=bigtreeEdo "Code") | [<img src="https://avatars0.githubusercontent.com/u/5000430?v=4" width="110px;"/><br /><sub>Colin McNeil</sub>](https://colinmcneil.me/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=ColinMcNeil "Code") | [<img src="https://avatars0.githubusercontent.com/u/421625?v=4" width="110px;"/><br /><sub>JoKneeMo</sub>](https://github.com/JoKneeMo)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JoKneeMo "Code") | [<img src="https://avatars0.githubusercontent.com/u/54849013?v=4" width="110px;"/><br /><sub>Joshi</sub>](http://www.redbridge.se)<br />[💻](https://github.com/snipe/snipe-it/commits?author=joshi-redbridge "Code") | [<img src="https://avatars2.githubusercontent.com/u/15731458?v=4" width="110px;"/><br /><sub>Anthony Burns</sub>](https://github.com/anthonypburns)<br />[💻](https://github.com/snipe/snipe-it/commits?author=anthonypburns "Code") | [<img src="https://avatars1.githubusercontent.com/u/63399474?v=4" width="110px;"/><br /><sub>johnson-yi</sub>](https://github.com/johnson-yi)<br />[💻](https://github.com/snipe/snipe-it/commits?author=johnson-yi "Code") | [<img src="https://avatars1.githubusercontent.com/u/1862720?v=4" width="110px;"/><br /><sub>Sanjay Govind</sub>](https://tangentmc.net)<br />[💻](https://github.com/snipe/snipe-it/commits?author=sanjay900 "Code") | </tr>
| [<img src="https://avatars0.githubusercontent.com/u/1255375?v=4" width="110px;"/><br /><sub>Peter Upfold</sub>](https://peter.upfold.org.uk/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=PeterUpfold "Code") | [<img src="https://avatars2.githubusercontent.com/u/961717?v=4" width="110px;"/><br /><sub>Jared Biel</sub>](https://github.com/jbiel)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jbiel "Code") | [<img src="https://avatars1.githubusercontent.com/u/1733625?v=4" width="110px;"/><br /><sub>Dampfklon</sub>](https://github.com/dampfklon)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dampfklon "Code") | [<img src="https://avatars2.githubusercontent.com/u/52973156?v=4" width="110px;"/><br /><sub>Charles Hamilton</sub>](https://communityclosing.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=chamilton-ccn "Code") | [<img src="https://avatars.githubusercontent.com/u/551789?v=4" width="110px;"/><br /><sub>Giuseppe Iannello</sub>](https://github.com/giannello)<br />[💻](https://github.com/snipe/snipe-it/commits?author=giannello "Code") | [<img src="https://avatars.githubusercontent.com/u/3691490?v=4" width="110px;"/><br /><sub>Peter Dave Hello</sub>](https://www.peterdavehello.org/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=PeterDaveHello "Code") | [<img src="https://avatars.githubusercontent.com/u/6106332?v=4" width="110px;"/><br /><sub>sigmoidal</sub>](https://github.com/sigmoidal)<br />[💻](https://github.com/snipe/snipe-it/commits?author=sigmoidal "Code") | <tr>
| [<img src="https://avatars.githubusercontent.com/u/2082554?v=4" width="110px;"/><br /><sub>Vincent Lainé</sub>](https://github.com/phenixdotnet)<br />[💻](https://github.com/snipe/snipe-it/commits?author=phenixdotnet "Code") | [<img src="https://avatars.githubusercontent.com/u/1943040?v=4" width="110px;"/><br /><sub>Lucas Pleß</sub>](http://www.lucas-pless.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=derlucas "Code") | [<img src="https://avatars.githubusercontent.com/u/472804?v=4" width="110px;"/><br /><sub>Ian Littman</sub>](http://twitter.com/iansltx)<br />[💻](https://github.com/snipe/snipe-it/commits?author=iansltx "Code") | [<img src="https://avatars.githubusercontent.com/u/3519029?v=4" width="110px;"/><br /><sub>João Paulo</sub>](https://github.com/PauloLuna)<br />[💻](https://github.com/snipe/snipe-it/commits?author=PauloLuna "Code") | [<img src="https://avatars.githubusercontent.com/u/70443365?v=4" width="110px;"/><br /><sub>ThoBur</sub>](https://github.com/ThoBur)<br />[💻](https://github.com/snipe/snipe-it/commits?author=ThoBur "Code") | [<img src="https://avatars.githubusercontent.com/u/1972329?v=4" width="110px;"/><br /><sub>Alexander Chibrikin</sub>](http://phpprofi.ru/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=alek13 "Code") | [<img src="https://avatars.githubusercontent.com/u/438332?v=4" width="110px;"/><br /><sub>Anthony Winstanley</sub>](https://github.com/winstan)<br />[💻](https://github.com/snipe/snipe-it/commits?author=winstan "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/YetAnotherCodeMonkey"><img src="https://avatars0.githubusercontent.com/u/13452757?v=3?s=110" width="110px;" alt="Drew"/><br /><sub><b>Drew</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=YetAnotherCodeMonkey" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/3075214?v=4" width="110px;"/><br /><sub>Folke</sub>](https://github.com/fashberg)<br />[💻](https://github.com/snipe/snipe-it/commits?author=fashberg "Code") | [<img src="https://avatars.githubusercontent.com/u/1351571?v=4" width="110px;"/><br /><sub>Bennett Blodinger</sub>](https://github.com/benwa)<br />[💻](https://github.com/snipe/snipe-it/commits?author=benwa "Code") | [<img src="https://avatars.githubusercontent.com/u/2974631?v=4" width="110px;"/><br /><sub>NMC</sub>](https://nmc.dev)<br />[💻](https://github.com/snipe/snipe-it/commits?author=ncareau "Code") | [<img src="https://avatars.githubusercontent.com/u/52182449?v=4" width="110px;"/><br /><sub>andres-baller</sub>](https://github.com/andres-baller)<br />[💻](https://github.com/snipe/snipe-it/commits?author=andres-baller "Code") | [<img src="https://avatars.githubusercontent.com/u/67109348?v=4" width="110px;"/><br /><sub>sean-borg</sub>](https://github.com/sean-borg)<br />[💻](https://github.com/snipe/snipe-it/commits?author=sean-borg "Code") | [<img src="https://avatars.githubusercontent.com/u/32170051?v=4" width="110px;"/><br /><sub>EDVLeer</sub>](https://github.com/EDVLeer)<br />[💻](https://github.com/snipe/snipe-it/commits?author=EDVLeer "Code") | [<img src="https://avatars.githubusercontent.com/u/23075196?v=4" width="110px;"/><br /><sub>Kurokat</sub>](https://github.com/Kurokat)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Kurokat "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/merid14"><img src="https://avatars0.githubusercontent.com/u/1342320?v=3?s=110" width="110px;" alt="Walter"/><br /><sub><b>Walter</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=merid14" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/915514?v=4" width="110px;"/><br /><sub>Kevin Köllmann</sub>](https://www.kevinkoellmann.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=koelle25 "Code") | [<img src="https://avatars.githubusercontent.com/u/49025941?v=4" width="110px;"/><br /><sub>sw-mreyes</sub>](https://github.com/sw-mreyes)<br />[💻](https://github.com/snipe/snipe-it/commits?author=sw-mreyes "Code") | [<img src="https://avatars.githubusercontent.com/u/70129?v=4" width="110px;"/><br /><sub>Joel Pittet</sub>](https://pittet.ca)<br />[💻](https://github.com/snipe/snipe-it/commits?author=joelpittet "Code") | [<img src="https://avatars.githubusercontent.com/u/792695?v=4" width="110px;"/><br /><sub>Eli Young</sub>](https://elyscape.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=elyscape "Code") | [<img src="https://avatars.githubusercontent.com/u/317015?v=4" width="110px;"/><br /><sub>Raell Dottin</sub>](https://github.com/raelldottin)<br />[💻](https://github.com/snipe/snipe-it/commits?author=raelldottin "Code") | [<img src="https://avatars.githubusercontent.com/u/1446856?v=4" width="110px;"/><br /><sub>Tom Misilo</sub>](https://github.com/misilot)<br />[💻](https://github.com/snipe/snipe-it/commits?author=misilot "Code") | [<img src="https://avatars.githubusercontent.com/u/4496300?v=4" width="110px;"/><br /><sub>David Davenne</sub>](http://david.davenne.be)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JuustoMestari "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/balous"><img src="https://avatars3.githubusercontent.com/u/11254614?v=3?s=110" width="110px;" alt="Petr Baloun"/><br /><sub><b>Petr Baloun</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=balous" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/9255772?v=4" width="110px;"/><br /><sub>Mark Stenglein</sub>](https://markstenglein.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=ocelotsloth "Code") | [<img src="https://avatars.githubusercontent.com/u/35658596?v=4" width="110px;"/><br /><sub>ajsy</sub>](https://github.com/ajsy)<br />[💻](https://github.com/snipe/snipe-it/commits?author=ajsy "Code") | [<img src="https://avatars.githubusercontent.com/u/3628035?v=4" width="110px;"/><br /><sub>Jan Kiesewetter</sub>](https://github.com/t3easy)<br />[💻](https://github.com/snipe/snipe-it/commits?author=t3easy "Code") | [<img src="https://avatars.githubusercontent.com/u/79449630?v=4" width="110px;"/><br /><sub>Tetrachloromethane250</sub>](https://github.com/Tetrachloromethane250)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Tetrachloromethane250 "Code") | [<img src="https://avatars.githubusercontent.com/u/22004482?v=4" width="110px;"/><br /><sub>Lars Kajes</sub>](https://www.kajes.se/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=kajes "Code") | [<img src="https://avatars.githubusercontent.com/u/13993216?v=4" width="110px;"/><br /><sub>Joly0</sub>](https://github.com/Joly0)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Joly0 "Code") | [<img src="https://avatars.githubusercontent.com/u/1501022?v=4" width="110px;"/><br /><sub>theburger</sub>](https://github.com/limeless)<br />[💻](https://github.com/snipe/snipe-it/commits?author=limeless "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/reidblomquist"><img src="https://avatars0.githubusercontent.com/u/6117660?v=3?s=110" width="110px;" alt="reidblomquist"/><br /><sub><b>reidblomquist</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=reidblomquist" title="Documentation">📖</a></td>
| [<img src="https://avatars.githubusercontent.com/u/36065681?v=4" width="110px;"/><br /><sub>David Valin Alonso</sub>](https://github.com/deivishome)<br />[💻](https://github.com/snipe/snipe-it/commits?author=deivishome "Code") | [<img src="https://avatars.githubusercontent.com/u/8290389?v=4" width="110px;"/><br /><sub>andreaci</sub>](https://github.com/andreaci)<br />[💻](https://github.com/snipe/snipe-it/commits?author=andreaci "Code") | [<img src="https://avatars.githubusercontent.com/u/1828542?v=4" width="110px;"/><br /><sub>Jelle Sebreghts</sub>](http://www.jellesebreghts.be)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Jelle-S "Code") | [<img src="https://avatars.githubusercontent.com/u/11180862?v=4" width="110px;"/><br /><sub>Michael Pietsch</sub>](https://github.com/Skywalker-11)<br /> | [<img src="https://avatars.githubusercontent.com/u/22068886?v=4" width="110px;"/><br /><sub>Masudul Haque Shihab</sub>](https://github.com/sh1hab)<br />[💻](https://github.com/snipe/snipe-it/commits?author=sh1hab "Code") | [<img src="https://avatars.githubusercontent.com/u/16099942?v=4" width="110px;"/><br /><sub>Supapong Areeprasertkul</sub>](http://www.freedomdive.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=zybersup "Code") | [<img src="https://avatars.githubusercontent.com/u/207358?v=4" width="110px;"/><br /><sub>Peter Sarossy</sub>](https://github.com/psarossy)<br />[💻](https://github.com/snipe/snipe-it/commits?author=psarossy "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/mathieuk"><img src="https://avatars0.githubusercontent.com/u/539914?v=3?s=110" width="110px;" alt="Mathieu Kooiman"/><br /><sub><b>Mathieu Kooiman</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=mathieuk" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/11823649?v=4" width="110px;"/><br /><sub>Renee Margaret McConahy</sub>](https://github.com/nepella)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nepella "Code") | [<img src="https://avatars.githubusercontent.com/u/5553884?v=4" width="110px;"/><br /><sub>JohnnyPicnic</sub>](https://github.com/JohnnyPicnic)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JohnnyPicnic "Code") | [<img src="https://avatars.githubusercontent.com/u/8799594?v=4" width="110px;"/><br /><sub>markbrule</sub>](https://github.com/markbrule)<br />[💻](https://github.com/snipe/snipe-it/commits?author=markbrule "Code") | [<img src="https://avatars.githubusercontent.com/u/1962801?v=4" width="110px;"/><br /><sub>Mike Campbell</sub>](https://github.com/mikecmpbll)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mikecmpbll "Code") | [<img src="https://avatars.githubusercontent.com/u/11973217?v=4" width="110px;"/><br /><sub>tbrconnect</sub>](https://github.com/tbrconnect)<br />[💻](https://github.com/snipe/snipe-it/commits?author=tbrconnect "Code") | [<img src="https://avatars.githubusercontent.com/u/12447225?v=4" width="110px;"/><br /><sub>kcoyo</sub>](https://github.com/kcoyo)<br />[💻](https://github.com/snipe/snipe-it/commits?author=kcoyo "Code") | [<img src="https://avatars.githubusercontent.com/u/494017?v=4" width="110px;"/><br /><sub>Travis Miller</sub>](https://travismiller.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=travismiller "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/csayre"><img src="https://avatars3.githubusercontent.com/u/6606421?v=3?s=110" width="110px;" alt="csayre"/><br /><sub><b>csayre</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=csayre" title="Documentation">📖</a></td>
| [<img src="https://avatars.githubusercontent.com/u/1975640?v=4" width="110px;"/><br /><sub>Evan Taylor</sub>](https://github.com/Delta5)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Delta5 "Code") | [<img src="https://avatars.githubusercontent.com/u/8735148?v=4" width="110px;"/><br /><sub>Petri Asikainen</sub>](https://github.com/PetriAsi)<br />[💻](https://github.com/snipe/snipe-it/commits?author=PetriAsi "Code") | [<img src="https://avatars.githubusercontent.com/u/11424540?v=4" width="110px;"/><br /><sub>derdeagle</sub>](https://github.com/derdeagle)<br />[💻](https://github.com/snipe/snipe-it/commits?author=derdeagle "Code") | [<img src="https://avatars.githubusercontent.com/u/176950?v=4" width="110px;"/><br /><sub>Mike Frysinger</sub>](https://wh0rd.org/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=vapier "Code") | [<img src="https://avatars.githubusercontent.com/u/22044358?v=4" width="110px;"/><br /><sub>ALPHA</sub>](https://github.com/AL4AL)<br />[💻](https://github.com/snipe/snipe-it/commits?author=AL4AL "Code") | [<img src="https://avatars.githubusercontent.com/u/1042587?v=4" width="110px;"/><br /><sub>FliegenKLATSCH</sub>](https://www.ifern.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=FliegenKLATSCH "Code") | [<img src="https://avatars.githubusercontent.com/u/442138?v=4" width="110px;"/><br /><sub>Jeremy Price</sub>](https://github.com/jerm)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jerm "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/adamdunson"><img src="https://avatars1.githubusercontent.com/u/768488?v=3?s=110" width="110px;" alt="Adam Dunson"/><br /><sub><b>Adam Dunson</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=adamdunson" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/84392209?v=4" width="110px;"/><br /><sub>Toreg87</sub>](https://github.com/Toreg87)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Toreg87 "Code") | [<img src="https://avatars.githubusercontent.com/u/67638596?v=4" width="110px;"/><br /><sub>Matthew Nickson</sub>](https://github.com/Computroniks)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Computroniks "Code") | [<img src="https://avatars.githubusercontent.com/u/1646397?v=4" width="110px;"/><br /><sub>Jethro Nederhof</sub>](https://jethron.id.au)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jethron "Code") | [<img src="https://avatars.githubusercontent.com/u/23289826?v=4" width="110px;"/><br /><sub>Oskar Stenberg</sub>](https://github.com/01ste02)<br />[💻](https://github.com/snipe/snipe-it/commits?author=01ste02 "Code") | [<img src="https://avatars.githubusercontent.com/u/82208283?v=4" width="110px;"/><br /><sub>Robert-Azelis</sub>](https://github.com/Robert-Azelis)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Robert-Azelis "Code") | [<img src="https://avatars.githubusercontent.com/u/60648387?v=4" width="110px;"/><br /><sub>Alexander William Smith</sub>](https://github.com/alwism)<br />[💻](https://github.com/snipe/snipe-it/commits?author=alwism "Code") | [<img src="https://avatars.githubusercontent.com/u/24418301?v=4" width="110px;"/><br /><sub>LEITWERK AG</sub>](https://www.leitwerk.de/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=leitwerk-ag "Code") | </tr>
| [<img src="https://avatars.githubusercontent.com/u/1911435?v=4" width="110px;"/><br /><sub>Adam</sub>](http://www.aboutcher.co.uk)<br />[💻](https://github.com/snipe/snipe-it/commits?author=adamboutcher "Code") | [<img src="https://avatars.githubusercontent.com/u/16104273?v=4" width="110px;"/><br /><sub>Ian</sub>](https://snksrv.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=sneak-it "Code") | [<img src="https://avatars.githubusercontent.com/u/4023909?v=4" width="110px;"/><br /><sub>Shao Yu-Lung (Allen)</sub>](http://blog.bestlong.idv.tw/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=bestlong "Code") | [<img src="https://avatars.githubusercontent.com/u/76475453?v=4" width="110px;"/><br /><sub>Haxatron</sub>](https://github.com/Haxatron)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Haxatron "Code") | [<img src="https://avatars.githubusercontent.com/u/88776392?v=4" width="110px;"/><br /><sub>PlaneNuts</sub>](https://github.com/PlaneNuts)<br />[💻](https://github.com/snipe/snipe-it/commits?author=PlaneNuts "Code") | [<img src="https://avatars.githubusercontent.com/u/3842948?v=4" width="110px;"/><br /><sub>Bradley Coudriet</sub>](http://bjcpgd.cias.rit.edu)<br />[💻](https://github.com/snipe/snipe-it/commits?author=exula "Code") | [<img src="https://avatars.githubusercontent.com/u/21966173?v=4" width="110px;"/><br /><sub>Dalton Durst</sub>](https://daltondur.st)<br />[💻](https://github.com/snipe/snipe-it/commits?author=UniversalSuperBox "Code") | <tr>
| [<img src="https://avatars.githubusercontent.com/u/38761237?v=4" width="110px;"/><br /><sub>Alex Janes</sub>](https://adagiohealth.org)<br />[💻](https://github.com/snipe/snipe-it/commits?author=adagioajanes "Code") | [<img src="https://avatars.githubusercontent.com/u/32387849?v=4" width="110px;"/><br /><sub>Nuraeil</sub>](https://github.com/nuraeil)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nuraeil "Code") | [<img src="https://avatars.githubusercontent.com/u/48162670?v=4" width="110px;"/><br /><sub>TenOfTens</sub>](https://github.com/TenOfTens)<br />[💻](https://github.com/snipe/snipe-it/commits?author=TenOfTens "Code") | [<img src="https://avatars.githubusercontent.com/u/9415391?v=4" width="110px;"/><br /><sub>waffle</sub>](https://ditisjens.be/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=insert-waffle "Code") | [<img src="https://avatars.githubusercontent.com/u/19945501?v=4" width="110px;"/><br /><sub>Yevhenii Huzii</sub>](https://github.com/qveensi)<br />[💻](https://github.com/snipe/snipe-it/commits?author=qveensi "Code") | [<img src="https://avatars.githubusercontent.com/u/3839381?v=4" width="110px;"/><br /><sub>Achmad Fienan Rahardianto</sub>](https://github.com/veenone)<br />[💻](https://github.com/snipe/snipe-it/commits?author=veenone "Code") | [<img src="https://avatars.githubusercontent.com/u/97299851?v=4" width="110px;"/><br /><sub>Christian Weirich</sub>](https://github.com/chrisweirich)<br />[💻](https://github.com/snipe/snipe-it/commits?author=chrisweirich "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/thehereward"><img src="https://avatars0.githubusercontent.com/u/5547470?v=3?s=110" width="110px;" alt="Hereward"/><br /><sub><b>Hereward</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=thehereward" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/1294403?v=4" width="110px;"/><br /><sub>denzfarid</sub>](https://github.com/denzfarid)<br /> | [<img src="https://avatars.githubusercontent.com/u/94018771?v=4" width="110px;"/><br /><sub>ntbutler-nbcs</sub>](https://github.com/ntbutler-nbcs)<br />[💻](https://github.com/snipe/snipe-it/commits?author=ntbutler-nbcs "Code") | [<img src="https://avatars.githubusercontent.com/u/172697?v=4" width="110px;"/><br /><sub>Naveen</sub>](https://naveensrinivasan.dev)<br />[💻](https://github.com/snipe/snipe-it/commits?author=naveensrinivasan "Code") | [<img src="https://avatars.githubusercontent.com/u/55674383?v=4" width="110px;"/><br /><sub>Mike Roquemore</sub>](https://github.com/mikeroq)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mikeroq "Code") | [<img src="https://avatars.githubusercontent.com/u/7991086?v=4" width="110px;"/><br /><sub>Daniel Reeder</sub>](https://github.com/reederda)<br />[🌍](#translation-reederda "Translation") [🌍](#translation-reederda "Translation") [💻](https://github.com/snipe/snipe-it/commits?author=reederda "Code") | [<img src="https://avatars.githubusercontent.com/u/109422491?v=4" width="110px;"/><br /><sub>vickyjaura183</sub>](https://github.com/vickyjaura183)<br />[💻](https://github.com/snipe/snipe-it/commits?author=vickyjaura183 "Code") | [<img src="https://avatars.githubusercontent.com/u/32363424?v=4" width="110px;"/><br /><sub>Peace</sub>](https://github.com/julian-piehl)<br />[💻](https://github.com/snipe/snipe-it/commits?author=julian-piehl "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/swoopdk"><img src="https://avatars0.githubusercontent.com/u/5802977?v=3?s=110" width="110px;" alt="swoopdk"/><br /><sub><b>swoopdk</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=swoopdk" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/231528?v=4" width="110px;"/><br /><sub>Kyle Gordon</sub>](https://github.com/kylegordon)<br />[💻](https://github.com/snipe/snipe-it/commits?author=kylegordon "Code") | [<img src="https://avatars.githubusercontent.com/u/53009155?v=4" width="110px;"/><br /><sub>Katharina Drexel</sub>](http://www.bfh.ch)<br />[💻](https://github.com/snipe/snipe-it/commits?author=sunflowerbofh "Code") | [<img src="https://avatars.githubusercontent.com/u/1931963?v=4" width="110px;"/><br /><sub>David Sferruzza</sub>](https://david.sferruzza.fr/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dsferruzza "Code") | [<img src="https://avatars.githubusercontent.com/u/19511639?v=4" width="110px;"/><br /><sub>Rick Nelson</sub>](https://github.com/rnelsonee)<br />[💻](https://github.com/snipe/snipe-it/commits?author=rnelsonee "Code") | [<img src="https://avatars.githubusercontent.com/u/94169344?v=4" width="110px;"/><br /><sub>BasO12</sub>](https://github.com/BasO12)<br />[💻](https://github.com/snipe/snipe-it/commits?author=BasO12 "Code") | [<img src="https://avatars.githubusercontent.com/u/111710123?v=4" width="110px;"/><br /><sub>Vautia</sub>](https://github.com/Vautia)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Vautia "Code") | [<img src="https://avatars.githubusercontent.com/u/28321?v=4" width="110px;"/><br /><sub>Chris Hartjes</sub>](http://www.littlehart.net/atthekeyboard)<br />[💻](https://github.com/snipe/snipe-it/commits?author=chartjes "Code") | <td align="center" valign="top" width="14.28%"><a href="https://linkedin.com/in/ahimta"><img src="https://avatars1.githubusercontent.com/u/3470403?v=3?s=110" width="110px;" alt="Abdullah Alansari"/><br /><sub><b>Abdullah Alansari</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Ahimta" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/2404584?v=4" width="110px;"/><br /><sub>geo-chen</sub>](https://github.com/geo-chen)<br />[💻](https://github.com/snipe/snipe-it/commits?author=geo-chen "Code") | [<img src="https://avatars.githubusercontent.com/u/6006620?v=4" width="110px;"/><br /><sub>Phan Nguyen</sub>](https://github.com/nh314)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nh314 "Code") | [<img src="https://avatars.githubusercontent.com/u/115993812?v=4" width="110px;"/><br /><sub>Iisakki Jaakkola</sub>](https://github.com/StarlessNights)<br />[💻](https://github.com/snipe/snipe-it/commits?author=StarlessNights "Code") | [<img src="https://avatars.githubusercontent.com/u/22633385?v=4" width="110px;"/><br /><sub>Ikko Ashimine</sub>](https://bandism.net/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=eltociear "Code") | [<img src="https://avatars.githubusercontent.com/u/56871540?v=4" width="110px;"/><br /><sub>Lukas Fehling</sub>](https://github.com/lukasfehling)<br />[💻](https://github.com/snipe/snipe-it/commits?author=lukasfehling "Code") | [<img src="https://avatars.githubusercontent.com/u/1975990?v=4" width="110px;"/><br /><sub>Fernando Almeida</sub>](https://github.com/fernando-almeida)<br />[💻](https://github.com/snipe/snipe-it/commits?author=fernando-almeida "Code") | [<img src="https://avatars.githubusercontent.com/u/116301219?v=4" width="110px;"/><br /><sub>akemidx</sub>](https://github.com/akemidx)<br />[💻](https://github.com/snipe/snipe-it/commits?author=akemidx "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/MicaelRodrigues"><img src="https://avatars0.githubusercontent.com/u/796443?v=3?s=110" width="110px;" alt="Micael Rodrigues"/><br /><sub><b>Micael Rodrigues</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=MicaelRodrigues" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/144778?v=4" width="110px;"/><br /><sub>Oguz Bilgic</sub>](http://oguz.site)<br />[💻](https://github.com/snipe/snipe-it/commits?author=oguzbilgic "Code") | [<img src="https://avatars.githubusercontent.com/u/9262438?v=4" width="110px;"/><br /><sub>Scooter Crawford</sub>](https://github.com/scoo73r)<br />[💻](https://github.com/snipe/snipe-it/commits?author=scoo73r "Code") | [<img src="https://avatars.githubusercontent.com/u/5957345?v=4" width="110px;"/><br /><sub>subdriven</sub>](https://github.com/subdriven)<br />[💻](https://github.com/snipe/snipe-it/commits?author=subdriven "Code") | [<img src="https://avatars.githubusercontent.com/u/658865?v=4" width="110px;"/><br /><sub>Andrew Savinykh</sub>](https://github.com/AndrewSav)<br />[💻](https://github.com/snipe/snipe-it/commits?author=AndrewSav "Code") | [<img src="https://avatars.githubusercontent.com/u/1155067?v=4" width="110px;"/><br /><sub>Tadayuki Onishi</sub>](https://kenchan0130.github.io)<br />[💻](https://github.com/snipe/snipe-it/commits?author=kenchan0130 "Code") | [<img src="https://avatars.githubusercontent.com/u/112496896?v=4" width="110px;"/><br /><sub>Florian</sub>](https://github.com/floschoepfer)<br />[💻](https://github.com/snipe/snipe-it/commits?author=floschoepfer "Code") | [<img src="https://avatars.githubusercontent.com/u/7305753?v=4" width="110px;"/><br /><sub>Spencer Long</sub>](http://spencerlong.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=spencerrlongg "Code") | <td align="center" valign="top" width="14.28%"><a href="http://macadmincorner.com"><img src="https://avatars0.githubusercontent.com/u/614564?v=3?s=110" width="110px;" alt="Patrick Gallagher"/><br /><sub><b>Patrick Gallagher</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=patgmac" title="Documentation">📖</a></td>
| [<img src="https://avatars.githubusercontent.com/u/1141514?v=4" width="110px;"/><br /><sub>Marcus Moore</sub>](https://github.com/marcusmoore)<br />[💻](https://github.com/snipe/snipe-it/commits?author=marcusmoore "Code") | [<img src="https://avatars.githubusercontent.com/u/570639?v=4" width="110px;"/><br /><sub>Martin Meredith</sub>](https://github.com/Mezzle)<br /> | [<img src="https://avatars.githubusercontent.com/u/5731963?v=4" width="110px;"/><br /><sub>dboth</sub>](http://dboth.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dboth "Code") | [<img src="https://avatars.githubusercontent.com/u/87536651?v=4" width="110px;"/><br /><sub>Zachary Fleck</sub>](https://github.com/zacharyfleck)<br />[💻](https://github.com/snipe/snipe-it/commits?author=zacharyfleck "Code") | [<img src="https://avatars.githubusercontent.com/u/74609912?v=4" width="110px;"/><br /><sub>VIKAAS-A</sub>](https://github.com/vikaas-cyper)<br />[💻](https://github.com/snipe/snipe-it/commits?author=vikaas-cyper "Code") | [<img src="https://avatars.githubusercontent.com/u/88882041?v=4" width="110px;"/><br /><sub>Abdul Kareem</sub>](https://github.com/ak-piracha)<br />[💻](https://github.com/snipe/snipe-it/commits?author=ak-piracha "Code") | [<img src="https://avatars.githubusercontent.com/u/111287779?v=4" width="110px;"/><br /><sub>NojoudAlshehri</sub>](https://github.com/NojoudAlshehri)<br />[💻](https://github.com/snipe/snipe-it/commits?author=NojoudAlshehri "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/Miliamber"><img src="https://avatars3.githubusercontent.com/u/7165922?v=3?s=110" width="110px;" alt="Miliamber"/><br /><sub><b>Miliamber</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Miliamber" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/54367449?v=4" width="110px;"/><br /><sub>Stefan Stidl</sub>](https://github.com/stefanstidlffg)<br />[💻](https://github.com/snipe/snipe-it/commits?author=stefanstidlffg "Code") | [<img src="https://avatars.githubusercontent.com/u/87803479?v=4" width="110px;"/><br /><sub>Quentin Aymard</sub>](https://github.com/qay21)<br />[💻](https://github.com/snipe/snipe-it/commits?author=qay21 "Code") | [<img src="https://avatars.githubusercontent.com/u/5396871?v=4" width="110px;"/><br /><sub>Grant Le Roux</sub>](https://github.com/cram42)<br />[💻](https://github.com/snipe/snipe-it/commits?author=cram42 "Code") | [<img src="https://avatars.githubusercontent.com/u/58479551?v=4" width="110px;"/><br /><sub>Bogdan</sub>](http://@singrity)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Singrity "Code") | [<img src="https://avatars.githubusercontent.com/u/3483684?v=4" width="110px;"/><br /><sub>mmanjos</sub>](https://github.com/mmanjos)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mmanjos "Code") | [<img src="https://avatars.githubusercontent.com/u/7429229?v=4" width="110px;"/><br /><sub>Abdelaziz Faki</sub>](https://azooz2014.github.io/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Azooz2014 "Code") | [<img src="https://avatars.githubusercontent.com/u/47315739?v=4" width="110px;"/><br /><sub>bilias</sub>](https://github.com/bilias)<br />[💻](https://github.com/snipe/snipe-it/commits?author=bilias "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/hawk554"><img src="https://avatars3.githubusercontent.com/u/861766?v=3?s=110" width="110px;" alt="hawk554"/><br /><sub><b>hawk554</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=hawk554" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/2565989?v=4" width="110px;"/><br /><sub>coach1988</sub>](https://github.com/coach1988)<br />[💻](https://github.com/snipe/snipe-it/commits?author=coach1988 "Code") | [<img src="https://avatars.githubusercontent.com/u/11910225?v=4" width="110px;"/><br /><sub>MrM</sub>](https://github.com/mauro-miatello)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mauro-miatello "Code") | [<img src="https://avatars.githubusercontent.com/u/60405354?v=4" width="110px;"/><br /><sub>koiakoia</sub>](https://github.com/koiakoia)<br />[💻](https://github.com/snipe/snipe-it/commits?author=koiakoia "Code") | [<img src="https://avatars.githubusercontent.com/u/5323832?v=4" width="110px;"/><br /><sub>Mustafa Online</sub>](https://github.com/mustafa-online)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mustafa-online "Code") | [<img src="https://avatars.githubusercontent.com/u/104601439?v=4" width="110px;"/><br /><sub>franceslui</sub>](https://github.com/franceslui)<br />[💻](https://github.com/snipe/snipe-it/commits?author=franceslui "Code") | [<img src="https://avatars.githubusercontent.com/u/125313163?v=4" width="110px;"/><br /><sub>Q4kK</sub>](https://github.com/Q4kK)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Q4kK "Code") | [<img src="https://avatars.githubusercontent.com/u/55590532?v=4" width="110px;"/><br /><sub>squintfox</sub>](https://github.com/squintfox)<br />[💻](https://github.com/snipe/snipe-it/commits?author=squintfox "Code") | </tr>
| [<img src="https://avatars.githubusercontent.com/u/1380084?v=4" width="110px;"/><br /><sub>Jeff Clay</sub>](https://github.com/jeffclay)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jeffclay "Code") | [<img src="https://avatars.githubusercontent.com/u/52716446?v=4" width="110px;"/><br /><sub>Phil J R</sub>](https://github.com/PP-JN-RL)<br />[💻](https://github.com/snipe/snipe-it/commits?author=PP-JN-RL "Code") | [<img src="https://avatars.githubusercontent.com/u/1496725?v=4" width="110px;"/><br /><sub>i_virus</sub>](https://www.corelight.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=chandanchowdhury "Code") | [<img src="https://avatars.githubusercontent.com/u/1020541?v=4" width="110px;"/><br /><sub>Paul Grime</sub>](https://github.com/gitgrimbo)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gitgrimbo "Code") | [<img src="https://avatars.githubusercontent.com/u/922815?v=4" width="110px;"/><br /><sub>Lee Porte</sub>](https://leeporte.co.uk)<br />[💻](https://github.com/snipe/snipe-it/commits?author=LeePorte "Code") | [<img src="https://avatars.githubusercontent.com/u/23613427?v=4" width="110px;"/><br /><sub>BRYAN </sub>](https://github.com/bryanlopezinc)<br />[💻](https://github.com/snipe/snipe-it/commits?author=bryanlopezinc "Code") [⚠️](https://github.com/snipe/snipe-it/commits?author=bryanlopezinc "Tests") | [<img src="https://avatars.githubusercontent.com/u/64061710?v=4" width="110px;"/><br /><sub>U-H-T</sub>](https://github.com/U-H-T)<br />[💻](https://github.com/snipe/snipe-it/commits?author=U-H-T "Code") | <tr>
| [<img src="https://avatars.githubusercontent.com/u/5395363?v=4" width="110px;"/><br /><sub>Matt Tyree</sub>](https://github.com/Tyree)<br />[📖](https://github.com/snipe/snipe-it/commits?author=Tyree "Documentation") | [<img src="https://avatars.githubusercontent.com/u/292081?v=4" width="110px;"/><br /><sub>Florent Bervas</sub>](http://spoontux.net)<br />[💻](https://github.com/snipe/snipe-it/commits?author=FlorentDotMe "Code") | [<img src="https://avatars.githubusercontent.com/u/4498077?v=4" width="110px;"/><br /><sub>Daniel Albertsen</sub>](https://ditscheri.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dbakan "Code") | [<img src="https://avatars.githubusercontent.com/u/100710244?v=4" width="110px;"/><br /><sub>r-xyz</sub>](https://github.com/r-xyz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=r-xyz "Code") | [<img src="https://avatars.githubusercontent.com/u/47491036?v=4" width="110px;"/><br /><sub>Steven Mainor</sub>](https://github.com/DrekiDegga)<br />[💻](https://github.com/snipe/snipe-it/commits?author=DrekiDegga "Code") | [<img src="https://avatars.githubusercontent.com/u/65785975?v=4" width="110px;"/><br /><sub>arne-kroeger</sub>](https://github.com/arne-kroeger)<br />[💻](https://github.com/snipe/snipe-it/commits?author=arne-kroeger "Code") | [<img src="https://avatars.githubusercontent.com/u/167117705?v=4" width="110px;"/><br /><sub>Glukose1</sub>](https://github.com/Glukose1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Glukose1 "Code") | <td align="center" valign="top" width="14.28%"><a href="http://jbirdkerr.net"><img src="https://avatars1.githubusercontent.com/u/1695622?v=3?s=110" width="110px;" alt="Justin Kerr"/><br /><sub><b>Justin Kerr</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=jbirdkerr" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/1197791?v=4" width="110px;"/><br /><sub>Scarzy</sub>](https://github.com/Scarzy)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Scarzy "Code") | [<img src="https://avatars.githubusercontent.com/u/37372069?v=4" width="110px;"/><br /><sub>setpill</sub>](https://github.com/setpill)<br />[💻](https://github.com/snipe/snipe-it/commits?author=setpill "Code") | [<img src="https://avatars.githubusercontent.com/u/3755203?v=4" width="110px;"/><br /><sub>swift2512</sub>](https://github.com/swift2512)<br />[🐛](https://github.com/snipe/snipe-it/issues?q=author%3Aswift2512 "Bug reports") | [<img src="https://avatars.githubusercontent.com/u/6136439?v=4" width="110px;"/><br /><sub>Darren Rainey</sub>](https://darrenraineys.co.uk)<br />[💻](https://github.com/snipe/snipe-it/commits?author=DarrenRainey "Code") | [<img src="https://avatars.githubusercontent.com/u/133033121?v=4" width="110px;"/><br /><sub>maciej-poleszczyk</sub>](https://github.com/maciej-poleszczyk)<br />[💻](https://github.com/snipe/snipe-it/commits?author=maciej-poleszczyk "Code") | [<img src="https://avatars.githubusercontent.com/u/143394709?v=4" width="110px;"/><br /><sub>Sebastian Groß</sub>](https://github.com/sgross-emlix)<br />[💻](https://github.com/snipe/snipe-it/commits?author=sgross-emlix "Code") | [<img src="https://avatars.githubusercontent.com/u/41107778?v=4" width="110px;"/><br /><sub>Anouar Touati</sub>](https://github.com/AnouarTouati)<br />[💻](https://github.com/snipe/snipe-it/commits?author=AnouarTouati "Code") | <td align="center" valign="top" width="14.28%"><a href="http://www.irasnyder.com/devel/"><img src="https://avatars3.githubusercontent.com/u/11426176?v=3?s=110" width="110px;" alt="Ira W. Snyder"/><br /><sub><b>Ira W. Snyder</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=irasnyd" title="Documentation">📖</a></td>
| [<img src="https://avatars.githubusercontent.com/u/25596663?v=4" width="110px;"/><br /><sub>aHVzY2g</sub>](https://github.com/aHVzY2g)<br />[💻](https://github.com/snipe/snipe-it/commits?author=aHVzY2g "Code") | [<img src="https://avatars.githubusercontent.com/u/13408130?v=4" width="110px;"/><br /><sub>林博仁 Buo-ren Lin</sub>](https://brlin.me)<br />[💻](https://github.com/snipe/snipe-it/commits?author=brlin-tw "Code") | [<img src="https://avatars.githubusercontent.com/u/18550946?v=4" width="110px;"/><br /><sub>Adugna Gizaw</sub>](https://orbalia.pythonanywhere.com/)<br />[🌍](#translation-addex12 "Translation") | [<img src="https://avatars.githubusercontent.com/u/760989?v=4" width="110px;"/><br /><sub>Jesse Ostrander</sub>](https://github.com/jostrander)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jostrander "Code") | [<img src="https://avatars.githubusercontent.com/u/31522486?v=4" width="110px;"/><br /><sub>James M</sub>](https://github.com/azmcnutt)<br />[💻](https://github.com/snipe/snipe-it/commits?author=azmcnutt "Code") | [<img src="https://avatars.githubusercontent.com/u/5183146?v=4" width="110px;"/><br /><sub>Fiala06</sub>](https://github.com/Fiala06)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Fiala06 "Code") | [<img src="https://avatars.githubusercontent.com/u/28693782?v=4" width="110px;"/><br /><sub>Nathan Taylor</sub>](https://github.com/ntaylor-86)<br />[💻](https://github.com/snipe/snipe-it/commits?author=ntaylor-86 "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/aalaily"><img src="https://avatars2.githubusercontent.com/u/2475759?v=3?s=110" width="110px;" alt="Aladin Alaily"/><br /><sub><b>Aladin Alaily</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=aalaily" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/16699443?v=4" width="110px;"/><br /><sub>fvollmer</sub>](https://github.com/fvollmer)<br />[💻](https://github.com/snipe/snipe-it/commits?author=fvollmer "Code") | [<img src="https://avatars.githubusercontent.com/u/109086466?v=4" width="110px;"/><br /><sub>36864</sub>](https://github.com/36864)<br />[💻](https://github.com/snipe/snipe-it/commits?author=36864 "Code") | [<img src="https://avatars.githubusercontent.com/u/365751?v=4" width="110px;"/><br /><sub>Daniel O'Connor</sub>](http://clockwerx.blogspot.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=CloCkWeRX "Code") | [<img src="https://avatars.githubusercontent.com/u/102852568?v=4" width="110px;"/><br /><sub>BeatSpark</sub>](https://github.com/BeatSpark)<br />[💻](https://github.com/snipe/snipe-it/commits?author=BeatSpark "Code") | [<img src="https://avatars.githubusercontent.com/u/59203607?v=4" width="110px;"/><br /><sub>mrdahbi</sub>](https://github.com/mrdahbi)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mrdahbi "Code") | [<img src="https://avatars.githubusercontent.com/u/6661332?v=4" width="110px;"/><br /><sub>Fabian Schmid</sub>](http://sr.solutions)<br />[💻](https://github.com/snipe/snipe-it/commits?author=chfsx "Code") | [<img src="https://avatars.githubusercontent.com/u/1288116?v=4" width="110px;"/><br /><sub>Chris Olin</sub>](https://www.chrisolin.com)<br />[💻](https://github.com/snipe/snipe-it/commits?author=realchrisolin "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/kobie-chasehansen"><img src="https://avatars0.githubusercontent.com/u/10247644?v=3?s=110" width="110px;" alt="Chase Hansen"/><br /><sub><b>Chase Hansen</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=kobie-chasehansen" title="Code">💻</a> <a href="#question-kobie-chasehansen" title="Answering Questions">💬</a> <a href="https://github.com/snipe/snipe-it/issues?q=author%3Akobie-chasehansen" title="Bug reports">🐛</a></td>
| [<img src="https://avatars.githubusercontent.com/u/3803132?v=4" width="110px;"/><br /><sub>Dan</sub>](https://github.com/mnemonicly)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mnemonicly "Code") | [<img src="https://avatars.githubusercontent.com/u/43917728?v=4" width="110px;"/><br /><sub>Nebel</sub>](https://github.com/NebelKreis)<br />[💻](https://github.com/snipe/snipe-it/commits?author=NebelKreis "Code") | [<img src="https://avatars.githubusercontent.com/u/132433803?v=4" width="110px;"/><br /><sub>test1337ahp</sub>](https://github.com/test1337ahp)<br />[💻](https://github.com/snipe/snipe-it/commits?author=test1337ahp "Code") | [<img src="https://avatars.githubusercontent.com/u/1916566?v=4" width="110px;"/><br /><sub>Jonathon Reinhart</sub>](https://github.com/JonathonReinhart)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JonathonReinhart "Code") | [<img src="https://avatars.githubusercontent.com/u/484742?v=4" width="110px;"/><br /><sub>aranar-pro</sub>](https://github.com/aranar-pro)<br />[💻](https://github.com/snipe/snipe-it/commits?author=aranar-pro "Code") | [<img src="https://avatars.githubusercontent.com/u/27019397?v=4" width="110px;"/><br /><sub>Phil</sub>](https://github.com/phil-flip)<br />[💻](https://github.com/snipe/snipe-it/commits?author=phil-flip "Code") | [<img src="https://avatars.githubusercontent.com/u/6473460?v=4" width="110px;"/><br /><sub>Steffy Fort</sub>](https://fe80.fr/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=fe80 "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/IDM-Helpdesk"><img src="https://avatars2.githubusercontent.com/u/13545400?v=3?s=110" width="110px;" alt="IDM Helpdesk"/><br /><sub><b>IDM Helpdesk</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=IDM-Helpdesk" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/3302372?v=4" width="110px;"/><br /><sub>Jared Busch</sub>](https://github.com/sorvani)<br />[💻](https://github.com/snipe/snipe-it/commits?author=sorvani "Code") | [<img src="https://avatars.githubusercontent.com/u/111956991?v=4" width="110px;"/><br /><sub>seanborg-codethink</sub>](https://github.com/seanborg-codethink)<br />[💻](https://github.com/snipe/snipe-it/commits?author=seanborg-codethink "Code") | [<img src="https://avatars.githubusercontent.com/u/160669961?v=4" width="110px;"/><br /><sub>dkaatz</sub>](https://github.com/dkaatz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dkaatz "Code") | [<img src="https://avatars.githubusercontent.com/u/827205?v=4" width="110px;"/><br /><sub>Daniel Ruf</sub>](https://threema.id/74SF7MW6?text=)<br />[💻](https://github.com/snipe/snipe-it/commits?author=DanielRuf "Code") | [<img src="https://avatars.githubusercontent.com/u/38883201?v=4" width="110px;"/><br /><sub>ahpaleus</sub>](https://github.com/ahpaleus)<br />[💻](https://github.com/snipe/snipe-it/commits?author=ahpaleus "Code") | [<img src="https://avatars.githubusercontent.com/u/22906055?v=4" width="110px;"/><br /><sub>Anh DAO-DUY</sub>](https://github.com/mink-adao-duy)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mink-adao-duy "Code") | [<img src="https://avatars.githubusercontent.com/u/4723453?v=4" width="110px;"/><br /><sub>Andres Gutierrez</sub>](https://github.com/Serdnad)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Serdnad "Code") | <td align="center" valign="top" width="14.28%"><a href="http://balticer.de"><img src="https://avatars2.githubusercontent.com/u/614439?v=3?s=110" width="110px;" alt="Kai"/><br /><sub><b>Kai</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=balticer" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/111083379?v=4" width="110px;"/><br /><sub>Warren White</sub>](https://github.com/wewhite)<br />[💻](https://github.com/snipe/snipe-it/commits?author=wewhite "Code") | [<img src="https://avatars.githubusercontent.com/u/2809241?v=4" width="110px;"/><br /><sub>Robin Temme</sub>](https://robintemme.de/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=robintemme "Code") | [<img src="https://avatars.githubusercontent.com/u/47008367?v=4" width="110px;"/><br /><sub>herroworrd</sub>](https://github.com/herroworrd)<br />[💻](https://github.com/snipe/snipe-it/commits?author=herroworrd "Code") | [<img src="https://avatars.githubusercontent.com/u/28558609?v=4" width="110px;"/><br /><sub>vicleos</sub>](https://mubiu.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=vicleos "Code") | [<img src="https://avatars.githubusercontent.com/u/1016780?v=4" width="110px;"/><br /><sub>Bob Clough</sub>](http://thinkl33t.co.uk/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=thinkl33t "Code") | [<img src="https://avatars.githubusercontent.com/u/10648463?v=4" width="110px;"/><br /><sub>Brandon Daniel Bailey</sub>](https://github.com/brandon-bailey)<br />[💻](https://github.com/snipe/snipe-it/commits?author=brandon-bailey "Code") | [<img src="https://avatars.githubusercontent.com/u/23556080?v=4" width="110px;"/><br /><sub>Marc Bartelt</sub>](https://github.com/marcquark)<br />[💻](https://github.com/snipe/snipe-it/commits?author=marcquark "Code") | <td align="center" valign="top" width="14.28%"><a href="http://www.michaeldaniels.me"><img src="https://avatars1.githubusercontent.com/u/8762511?v=3?s=110" width="110px;" alt="Michael Daniels"/><br /><sub><b>Michael Daniels</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=mdaniels5757" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/18286893?v=4" width="110px;"/><br /><sub>manu-crealytics</sub>](https://github.com/manu-crealytics)<br />[💻](https://github.com/snipe/snipe-it/commits?author=manu-crealytics "Code") | [<img src="https://avatars.githubusercontent.com/u/18245993?v=4" width="110px;"/><br /><sub>Konstantin Köhring</sub>](https://www.galaxy102.de/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Galaxy102 "Code") | [<img src="https://avatars.githubusercontent.com/u/685167?v=4" width="110px;"/><br /><sub>Deloz</sub>](https://deloz.net/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=deloz "Code") | [<img src="https://avatars.githubusercontent.com/u/2682426?v=4" width="110px;"/><br /><sub>Martin Berg</sub>](https://github.com/mbrrg)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mbrrg "Code") | [<img src="https://avatars.githubusercontent.com/u/3694534?v=4" width="110px;"/><br /><sub>Richard Schwab</sub>](https://github.com/Nothing4You)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Nothing4You "Code") | [<img src="https://avatars.githubusercontent.com/u/8959676?v=4" width="110px;"/><br /><sub>Rick Heil</sub>](https://rickheil.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=rickheil "Code") | [<img src="https://avatars.githubusercontent.com/u/397106?v=4" width="110px;"/><br /><sub>Ross Crawford-d'Heureuse</sub>](https://github.com/rosscdh)<br />[💻](https://github.com/snipe/snipe-it/commits?author=rosscdh "Code") | </tr>
| [<img src="https://avatars.githubusercontent.com/u/1621107?v=4" width="110px;"/><br /><sub>Ryan McGuire</sub>](https://github.com/McG800)<br />[💻](https://github.com/snipe/snipe-it/commits?author=McG800 "Code") | [<img src="https://avatars.githubusercontent.com/u/77835667?v=4" width="110px;"/><br /><sub>SBrown2021</sub>](https://github.com/SBrown2021)<br />[💻](https://github.com/snipe/snipe-it/commits?author=SBrown2021 "Code") | [<img src="https://avatars.githubusercontent.com/u/8780913?v=4" width="110px;"/><br /><sub>Serkan</sub>](https://github.com/serkanerip)<br />[💻](https://github.com/snipe/snipe-it/commits?author=serkanerip "Code") | [<img src="https://avatars.githubusercontent.com/u/63188620?v=4" width="110px;"/><br /><sub>Shanks</sub>](https://www.yudelei.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Shankschn "Code") | [<img src="https://avatars.githubusercontent.com/u/198525698?v=4" width="110px;"/><br /><sub>cendai-mis</sub>](https://github.com/cendai-mis)<br />[💻](https://github.com/snipe/snipe-it/commits?author=cendai-mis "Code") | [<img src="https://avatars.githubusercontent.com/u/8724583?v=4" width="110px;"/><br /><sub>Shaun McPeck</sub>](https://smcpeck.github.io/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=smcpeck "Code") | [<img src="https://avatars.githubusercontent.com/u/1378836?v=4" width="110px;"/><br /><sub>Stephen</sub>](https://github.com/snazy2000)<br />[💻](https://github.com/snipe/snipe-it/commits?author=snazy2000 "Code") | <tr>
| [<img src="https://avatars.githubusercontent.com/u/4462739?v=4" width="110px;"/><br /><sub>Steven</sub>](http://nevets82.github.io/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Nevets82 "Code") | [<img src="https://avatars.githubusercontent.com/u/29017267?v=4" width="110px;"/><br /><sub>Mateus Villar</sub>](https://mateusvillar.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Mateus-Romera "Code") | [<img src="https://avatars.githubusercontent.com/u/12749393?v=4" width="110px;"/><br /><sub>Matthew Zackschewski</sub>](https://github.com/mzack5020)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mzack5020 "Code") | [<img src="https://avatars.githubusercontent.com/u/12660103?v=4" width="110px;"/><br /><sub>Matthias Frei</sub>](https://www.frei.media/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=firefrei "Code") | [<img src="https://avatars.githubusercontent.com/u/824840?v=4" width="110px;"/><br /><sub>Nenad Ticaric</sub>](https://github.com/nticaric)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nticaric "Code") | [<img src="https://avatars.githubusercontent.com/u/706439?v=4" width="110px;"/><br /><sub>Nikolay Didenko</sub>](https://github.com/Scorcher)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Scorcher "Code") | [<img src="https://avatars.githubusercontent.com/u/5457236?v=4" width="110px;"/><br /><sub>Nuno Maduro</sub>](https://nunomaduro.com/sponsorships)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nunomaduro "Code") | <td align="center" valign="top" width="14.28%"><a href="http://tomcastleman.me"><img src="https://avatars3.githubusercontent.com/u/1532660?v=3?s=110" width="110px;" alt="Tom Castleman"/><br /><sub><b>Tom Castleman</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=tomcastleman" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/8883074?v=4" width="110px;"/><br /><sub>Oliver Walerys</sub>](https://tektikhq.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=owalerys "Code") | [<img src="https://avatars.githubusercontent.com/u/3102039?v=4" width="110px;"/><br /><sub>R. Christian McDonald</sub>](https://keybase.io/rcmcdonald91)<br />[💻](https://github.com/snipe/snipe-it/commits?author=rcmcdonald91 "Code") | [<img src="https://avatars.githubusercontent.com/u/1525581?v=4" width="110px;"/><br /><sub>nix</sub>](https://nnix.net/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nixn "Code") | [<img src="https://avatars.githubusercontent.com/u/55462380?v=4" width="110px;"/><br /><sub>octobunny</sub>](https://github.com/octobunny)<br />[💻](https://github.com/snipe/snipe-it/commits?author=octobunny "Code") | [<img src="https://avatars.githubusercontent.com/u/8558670?v=4" width="110px;"/><br /><sub>Ryan</sub>](https://github.com/sreyemnayr)<br />[💻](https://github.com/snipe/snipe-it/commits?author=sreyemnayr "Code") | [<img src="https://avatars.githubusercontent.com/u/1501022?v=4" width="110px;"/><br /><sub>p3nj</sub>](https://benji.ltd/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=p3nj "Code") | [<img src="https://avatars.githubusercontent.com/u/6201617?v=4" width="110px;"/><br /><sub>Tim White</sub>](https://github.com/timwsuqld)<br />[💻](https://github.com/snipe/snipe-it/commits?author=timwsuqld "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/DanielNemanic"><img src="https://avatars3.githubusercontent.com/u/10723243?v=3?s=110" width="110px;" alt="Daniel Nemanic"/><br /><sub><b>Daniel Nemanic</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=DanielNemanic" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/22473767?v=4" width="110px;"/><br /><sub>yannikp</sub>](https://github.com/yannikp)<br />[💻](https://github.com/snipe/snipe-it/commits?author=yannikp "Code") | [<img src="https://avatars.githubusercontent.com/u/20525448?v=4" width="110px;"/><br /><sub>victoria</sub>](https://github.com/viclou)<br />[💻](https://github.com/snipe/snipe-it/commits?author=viclou "Code") | [<img src="https://avatars.githubusercontent.com/u/40685314?v=4" width="110px;"/><br /><sub>Valentyn Tulub</sub>](https://github.com/valentyntu)<br />[💻](https://github.com/snipe/snipe-it/commits?author=valentyntu "Code") | [<img src="https://avatars.githubusercontent.com/u/864520?v=4" width="110px;"/><br /><sub>Wouter van Os</sub>](http://wouter0100.nl/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Wouter0100 "Code") | [<img src="https://avatars.githubusercontent.com/u/3946540?v=4" width="110px;"/><br /><sub>Wyatt Teeter</sub>](https://www.linkedin.com/in/wyatt-teeter)<br />[💻](https://github.com/snipe/snipe-it/commits?author=xWyatt "Code") | [<img src="https://avatars.githubusercontent.com/u/1596124?v=4" width="110px;"/><br /><sub>Yorick Terweijden</sub>](https://github.com/terwey)<br />[💻](https://github.com/snipe/snipe-it/commits?author=terwey "Code") | [<img src="https://avatars.githubusercontent.com/u/69298836?v=4" width="110px;"/><br /><sub>bmkalle</sub>](https://github.com/bmkalle)<br />[💻](https://github.com/snipe/snipe-it/commits?author=bmkalle "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/southwolf"><img src="https://avatars0.githubusercontent.com/u/150648?v=3?s=110" width="110px;" alt="SouthWolf"/><br /><sub><b>SouthWolf</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=southwolf" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/28403467?v=4" width="110px;"/><br /><sub>bricelabelle</sub>](https://github.com/bricelabelle)<br />[💻](https://github.com/snipe/snipe-it/commits?author=bricelabelle "Code") | [<img src="https://avatars.githubusercontent.com/u/97770090?v=4" width="110px;"/><br /><sub>corydlamb</sub>](https://github.com/corydlamb)<br />[💻](https://github.com/snipe/snipe-it/commits?author=corydlamb "Code") | [<img src="https://avatars.githubusercontent.com/u/1154133?v=4" width="110px;"/><br /><sub>Diogenes S. Jesus</sub>](http://twitter.com/splash)<br />[💻](https://github.com/snipe/snipe-it/commits?author=splashx "Code") | [<img src="https://avatars.githubusercontent.com/u/5826629?v=4" width="110px;"/><br /><sub>D M</sub>](https://github.com/dkmansion)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dkmansion "Code") | [<img src="https://avatars.githubusercontent.com/u/14837699?v=4" width="110px;"/><br /><sub>Dustin B</sub>](https://github.com/Jarli01)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Jarli01 "Code") | [<img src="https://avatars.githubusercontent.com/u/348344?v=4" width="110px;"/><br /><sub>Fabian Grutschus</sub>](https://github.com/fabiang)<br />[💻](https://github.com/snipe/snipe-it/commits?author=fabiang "Code") | [<img src="https://avatars.githubusercontent.com/u/1491053?v=4" width="110px;"/><br /><sub>MelonSmasher</sub>](https://github.com/MelonSmasher)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MelonSmasher "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/ivarne"><img src="https://avatars2.githubusercontent.com/u/131616?v=3?s=110" width="110px;" alt="Ivar Nesje"/><br /><sub><b>Ivar Nesje</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=ivarne" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/80526133?v=4" width="110px;"/><br /><sub>AlexanderWPapyrus</sub>](https://github.com/AlexanderWPapyrus)<br />[💻](https://github.com/snipe/snipe-it/commits?author=AlexanderWPapyrus "Code") | [<img src="https://avatars.githubusercontent.com/u/306231?v=4" width="110px;"/><br /><sub>Alexandr Hacicheant</sub>](https://github.com/disc)<br />[💻](https://github.com/snipe/snipe-it/commits?author=disc "Code") | [<img src="https://avatars.githubusercontent.com/u/3032891?v=4" width="110px;"/><br /><sub>Hex</sub>](https://hex128.io/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=hex128 "Code") | [<img src="https://avatars.githubusercontent.com/u/8697942?v=4" width="110px;"/><br /><sub>Arunas Skirius</sub>](https://github.com/arukompas)<br />[💻](https://github.com/snipe/snipe-it/commits?author=arukompas "Code") | [<img src="https://avatars.githubusercontent.com/u/104396?v=4" width="110px;"/><br /><sub>Ben Periton</sub>](https://github.com/benperiton)<br />[💻](https://github.com/snipe/snipe-it/commits?author=benperiton "Code") | [<img src="https://avatars.githubusercontent.com/u/11906832?v=4" width="110px;"/><br /><sub>Byron Wolfman</sub>](https://wolfman.dev/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=byronwolfman "Code") | [<img src="https://avatars.githubusercontent.com/u/56485508?v=4" width="110px;"/><br /><sub>Calvin</sub>](https://github.com/CalvinSchwartz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=CalvinSchwartz "Code") | <td align="center" valign="top" width="14.28%"><a href="http://www.j0k3r.net"><img src="https://avatars1.githubusercontent.com/u/62333?v=3?s=110" width="110px;" alt="Jérémy Benoist"/><br /><sub><b>Jérémy Benoist</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=j0k3r" title="Documentation">📖</a></td>
| [<img src="https://avatars.githubusercontent.com/u/181059?v=4" width="110px;"/><br /><sub>Juan Font</sub>](https://github.com/juanfont)<br />[💻](https://github.com/snipe/snipe-it/commits?author=juanfont "Code") | [<img src="https://avatars.githubusercontent.com/u/13137708?v=4" width="110px;"/><br /><sub>Juho Taipale</sub>](https://github.com/juhotaipale)<br />[💻](https://github.com/snipe/snipe-it/commits?author=juhotaipale "Code") | [<img src="https://avatars.githubusercontent.com/u/1007419?v=4" width="110px;"/><br /><sub>Korvin Szanto</sub>](https://github.com/KorvinSzanto)<br />[💻](https://github.com/snipe/snipe-it/commits?author=KorvinSzanto "Code") | [<img src="https://avatars.githubusercontent.com/u/8513053?v=4" width="110px;"/><br /><sub>Lewis Foster</sub>](https://lewisfoster.foo/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=sniff122 "Code") | [<img src="https://avatars.githubusercontent.com/u/33877541?v=4" width="110px;"/><br /><sub>Logan Swartzendruber</sub>](https://github.com/loganswartz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=loganswartz "Code") | [<img src="https://avatars.githubusercontent.com/u/1156208?v=4" width="110px;"/><br /><sub>Lorenzo P.</sub>](https://github.com/lopezio)<br />[💻](https://github.com/snipe/snipe-it/commits?author=lopezio "Code") | [<img src="https://avatars.githubusercontent.com/u/33946590?v=4" width="110px;"/><br /><sub>Lukas Jung</sub>](https://github.com/m4us1ne)<br />[💻](https://github.com/snipe/snipe-it/commits?author=m4us1ne "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/cleathley"><img src="https://avatars2.githubusercontent.com/u/724344?v=3?s=110" width="110px;" alt="Chris Leathley"/><br /><sub><b>Chris Leathley</b></sub></a><br /><a href="#infra-cleathley" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
| [<img src="https://avatars.githubusercontent.com/u/10965027?v=4" width="110px;"/><br /><sub>Ellie</sub>](https://leafedfox.xyz/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=LeafedFox "Code") | [<img src="https://avatars.githubusercontent.com/u/20960555?v=4" width="110px;"/><br /><sub>GA Stamper</sub>](https://github.com/gastamper)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gastamper "Code") | [<img src="https://avatars.githubusercontent.com/u/206553556?v=4" width="110px;"/><br /><sub>Guillaume Lefranc</sub>](https://github.com/gl-pup)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gl-pup "Code") | [<img src="https://avatars.githubusercontent.com/u/733892?v=4" width="110px;"/><br /><sub>Hajo Möller</sub>](https://github.com/dasjoe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dasjoe "Code") | [<img src="https://avatars.githubusercontent.com/u/3420063?v=4" width="110px;"/><br /><sub>Istvan Basa</sub>](https://github.com/pottom)<br />[💻](https://github.com/snipe/snipe-it/commits?author=pottom "Code") | [<img src="https://avatars.githubusercontent.com/u/810824?v=4" width="110px;"/><br /><sub>JJ Asghar</sub>](https://jjasghar.github.io/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jjasghar "Code") | [<img src="https://avatars.githubusercontent.com/u/40404495?v=4" width="110px;"/><br /><sub>James E. Msenga</sub>](https://github.com/JemCdo)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JemCdo "Code") | <td align="center" valign="top" width="14.28%"><a href="https://github.com/splaer"><img src="https://avatars0.githubusercontent.com/u/972498?v=3?s=110" width="110px;" alt="splaer"/><br /><sub><b>splaer</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/issues?q=author%3Asplaer" title="Bug reports">🐛</a> <a href="https://github.com/snipe/snipe-it/commits?author=splaer" title="Code">💻</a></td>
| [<img src="https://avatars.githubusercontent.com/u/6865786?v=4" width="110px;"/><br /><sub>Jan Felix Wiebe</sub>](https://github.com/jfwiebe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jfwiebe "Code") | [<img src="https://avatars.githubusercontent.com/u/43412008?v=4" width="110px;"/><br /><sub>Jo Drexl</sub>](https://www.nfon.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=drexljo "Code") | [<img src="https://avatars.githubusercontent.com/u/4807843?v=4" width="110px;"/><br /><sub>Austin Sasko</sub>](https://github.com/austinsasko)<br />[💻](https://github.com/snipe/snipe-it/commits?author=austinsasko "Code") | [<img src="https://avatars.githubusercontent.com/u/4875039?v=4" width="110px;"/><br /><sub>Jasson</sub>](http://jassoncordones.github.io)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JassonCordones "Code") | [<img src="https://avatars.githubusercontent.com/u/76069640?v=4" width="110px;"/><br /><sub>Okean</sub>](https://github.com/Tinyblargon)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Tinyblargon "Code") | [<img src="https://avatars.githubusercontent.com/u/6515064?v=4" width="110px;"/><br /><sub>Alejandro Medrano</sub>](https://www.lst.tfo.upm.es/alejandro-medrano/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=amedranogil "Code") | [<img src="https://avatars.githubusercontent.com/u/58696401?v=4" width="110px;"/><br /><sub>Lukas Kraic</sub>](https://github.com/lukaskraic)<br />[💻](https://github.com/snipe/snipe-it/commits?author=lukaskraic "Code") | </tr>
| [<img src="https://avatars.githubusercontent.com/u/1571724?v=4" width="110px;"/><br /><sub>Герхард PICCORO Lenz McKAY </sub>](https://github-readme-stats.vercel.app/api?username=mckaygerhard)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mckaygerhard "Code") | [<img src="https://avatars.githubusercontent.com/u/15015119?v=4" width="110px;"/><br /><sub>Johannes Pollitt</sub>](https://github.com/FlorestanII)<br />[💻](https://github.com/snipe/snipe-it/commits?author=FlorestanII "Code") | [<img src="https://avatars.githubusercontent.com/u/14185442?v=4" width="110px;"/><br /><sub>Michael Strobel</sub>](https://strobelm.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=strobelm "Code") | [<img src="https://avatars.githubusercontent.com/u/634790?v=4" width="110px;"/><br /><sub>Nicky West</sub>](http://nickwest.me)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nickwest "Code") | [<img src="https://avatars.githubusercontent.com/u/1347327?v=4" width="110px;"/><br /><sub>akaspeh1</sub>](https://github.com/akaspeh1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=akaspeh1 "Code") | <tr>
<td align="center" valign="top" width="14.28%"><a href="http://www.joeferguson.me"><img src="https://avatars1.githubusercontent.com/u/967362?v=3?s=110" width="110px;" alt="Joe Ferguson"/><br /><sub><b>Joe Ferguson</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=svpernova09" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/diwanicki"><img src="https://avatars3.githubusercontent.com/u/6108682?v=3?s=110" width="110px;" alt="diwanicki"/><br /><sub><b>diwanicki</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=diwanicki" title="Code">💻</a> <a href="https://github.com/snipe/snipe-it/commits?author=diwanicki" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/pakkua80"><img src="https://avatars3.githubusercontent.com/u/2527115?v=3?s=110" width="110px;" alt="Lee Thoong Ching"/><br /><sub><b>Lee Thoong Ching</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=pakkua80" title="Documentation">📖</a> <a href="https://github.com/snipe/snipe-it/commits?author=pakkua80" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://shu.io"><img src="https://avatars1.githubusercontent.com/u/461491?v=3?s=110" width="110px;" alt="Marek Šuppa"/><br /><sub><b>Marek Šuppa</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=mrshu" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mizar1616"><img src="https://avatars1.githubusercontent.com/u/8693762?v=3?s=110" width="110px;" alt="Juan J. Martinez"/><br /><sub><b>Juan J. Martinez</b></sub></a><br /><a href="#translation-mizar1616" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rrdial"><img src="https://avatars1.githubusercontent.com/u/1458388?v=3?s=110" width="110px;" alt="R Ryan Dial"/><br /><sub><b>R Ryan Dial</b></sub></a><br /><a href="#translation-rrdial" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/burlito"><img src="https://avatars2.githubusercontent.com/u/2871745?v=3?s=110" width="110px;" alt="Andrej Manduch"/><br /><sub><b>Andrej Manduch</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=burlito" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://www.cordeos.com"><img src="https://avatars0.githubusercontent.com/u/8341172?v=3?s=110" width="110px;" alt="Jay Richards"/><br /><sub><b>Jay Richards</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=technogenus" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://necurity.co.uk"><img src="https://avatars2.githubusercontent.com/u/7295127?v=3?s=110" width="110px;" alt="Alexander Innes"/><br /><sub><b>Alexander Innes</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=leostat" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://buzzedword.codes"><img src="https://avatars2.githubusercontent.com/u/334485?v=3?s=110" width="110px;" alt="Danny Garcia"/><br /><sub><b>Danny Garcia</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=buzzedword" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/archpoint"><img src="https://avatars2.githubusercontent.com/u/366855?v=3?s=110" width="110px;" alt="archpoint"/><br /><sub><b>archpoint</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=archpoint" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.jakemcgraw.com"><img src="https://avatars1.githubusercontent.com/u/67991?v=3?s=110" width="110px;" alt="Jake McGraw"/><br /><sub><b>Jake McGraw</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=jakemcgraw" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/FleischKarussel"><img src="https://avatars1.githubusercontent.com/u/1714374?v=3?s=110" width="110px;" alt="FleischKarussel"/><br /><sub><b>FleischKarussel</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=FleischKarussel" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/feeva"><img src="https://avatars3.githubusercontent.com/u/319644?v=3?s=110" width="110px;" alt="Dylan Yi"/><br /><sub><b>Dylan Yi</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=feeva" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://FlashingCursor.com"><img src="https://avatars2.githubusercontent.com/u/857740?v=3?s=110" width="110px;" alt="Gil Rutkowski"/><br /><sub><b>Gil Rutkowski</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=flashingcursor" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.desmondmorris.com"><img src="https://avatars3.githubusercontent.com/u/129360?v=3?s=110" width="110px;" alt="Desmond Morris"/><br /><sub><b>Desmond Morris</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=desmondmorris" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://peelman.us"><img src="https://avatars2.githubusercontent.com/u/52936?v=3?s=110" width="110px;" alt="Nick Peelman"/><br /><sub><b>Nick Peelman</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=peelman" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://abrahamvegh.com"><img src="https://avatars0.githubusercontent.com/u/53161?v=3?s=110" width="110px;" alt="Abraham Vegh"/><br /><sub><b>Abraham Vegh</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=abrahamvegh" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rashivkp"><img src="https://avatars0.githubusercontent.com/u/2818680?v=3?s=110" width="110px;" alt="Mohamed Rashid"/><br /><sub><b>Mohamed Rashid</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=rashivkp" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://hinchk.github.io"><img src="https://avatars3.githubusercontent.com/u/1509456?v=3?s=110" width="110px;" alt="Kasey"/><br /><sub><b>Kasey</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=HinchK" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/BrettFagerlund"><img src="https://avatars2.githubusercontent.com/u/10522541?v=3?s=110" width="110px;" alt="Brett"/><br /><sub><b>Brett</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=BrettFagerlund" title="Tests">⚠️</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://jasonspriggs.com"><img src="https://avatars2.githubusercontent.com/u/16108587?v=3?s=110" width="110px;" alt="Jason Spriggs"/><br /><sub><b>Jason Spriggs</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=jasonspriggs" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://n8felton.wordpress.com"><img src="https://avatars2.githubusercontent.com/u/1134568?v=3?s=110" width="110px;" alt="Nate Felton"/><br /><sub><b>Nate Felton</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=n8felton" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://homepages.dcc.ufmg.br/~manassesferreira"><img src="https://avatars2.githubusercontent.com/u/14036694?v=3?s=110" width="110px;" alt="Manasses Ferreira"/><br /><sub><b>Manasses Ferreira</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=manassesferreira" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/steveelwood"><img src="https://avatars0.githubusercontent.com/u/15913949?v=3?s=110" width="110px;" alt="Steve"/><br /><sub><b>Steve</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=steveelwood" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://twitter.com/matc"><img src="https://avatars1.githubusercontent.com/u/3361683?v=3?s=110" width="110px;" alt="matc"/><br /><sub><b>matc</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=matc" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.davisracingteam.com"><img src="https://avatars3.githubusercontent.com/u/7405702?v=3?s=110" width="110px;" alt="Cole R. Davis"/><br /><sub><b>Cole R. Davis</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=VanillaNinjaD" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gibsonjoshua55"><img src="https://avatars2.githubusercontent.com/u/10167681?v=3?s=110" width="110px;" alt="gibsonjoshua55"/><br /><sub><b>gibsonjoshua55</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=gibsonjoshua55" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zwerch"><img src="https://avatars2.githubusercontent.com/u/2809241?v=4?s=110" width="110px;" alt="Robin Temme"/><br /><sub><b>Robin Temme</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=zwerch" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/imanghafoori1"><img src="https://avatars0.githubusercontent.com/u/6961695?v=4?s=110" width="110px;" alt="Iman"/><br /><sub><b>Iman</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=imanghafoori1" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/richardhofman6"><img src="https://avatars1.githubusercontent.com/u/6551003?v=4?s=110" width="110px;" alt="Richard Hofman"/><br /><sub><b>Richard Hofman</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=richardhofman6" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gizzmojr"><img src="https://avatars0.githubusercontent.com/u/3697569?v=4?s=110" width="110px;" alt="gizzmojr"/><br /><sub><b>gizzmojr</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=gizzmojr" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/imjennyli"><img src="https://avatars3.githubusercontent.com/u/404729?v=4?s=110" width="110px;" alt="Jenny Li"/><br /><sub><b>Jenny Li</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=imjennyli" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/GeoffYoung"><img src="https://avatars0.githubusercontent.com/u/869227?v=4?s=110" width="110px;" alt="Geoff Young"/><br /><sub><b>Geoff Young</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=GeoffYoung" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.elliotblackburn.com"><img src="https://avatars3.githubusercontent.com/u/1068477?v=4?s=110" width="110px;" alt="Elliot Blackburn"/><br /><sub><b>Elliot Blackburn</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=BlueHatbRit" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://andmemasin.eu"><img src="https://avatars1.githubusercontent.com/u/6357451?v=4?s=110" width="110px;" alt="Tõnis Ormisson"/><br /><sub><b>Tõnis Ormisson</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=TonisOrmisson" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.nicolai-essig.de"><img src="https://avatars0.githubusercontent.com/u/449411?v=4?s=110" width="110px;" alt="Nicolai Essig"/><br /><sub><b>Nicolai Essig</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=thakilla" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/techincolor"><img src="https://avatars1.githubusercontent.com/u/14809698?v=4?s=110" width="110px;" alt="Danielle"/><br /><sub><b>Danielle</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=techincolor" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/TheVakman"><img src="https://avatars1.githubusercontent.com/u/18545156?v=4?s=110" width="110px;" alt="Lawrence"/><br /><sub><b>Lawrence</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=TheVakman" title="Tests">⚠️</a> <a href="https://github.com/snipe/snipe-it/issues?q=author%3ATheVakman" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/uknzaeinozpas"><img src="https://avatars1.githubusercontent.com/u/22473767?v=4?s=110" width="110px;" alt="uknzaeinozpas"/><br /><sub><b>uknzaeinozpas</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=uknzaeinozpas" title="Tests">⚠️</a> <a href="https://github.com/snipe/snipe-it/commits?author=uknzaeinozpas" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gelob"><img src="https://avatars3.githubusercontent.com/u/422752?v=4?s=110" width="110px;" alt="Ryan"/><br /><sub><b>Ryan</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Gelob" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/vcordes79"><img src="https://avatars1.githubusercontent.com/u/10672546?v=4?s=110" width="110px;" alt="vcordes79"/><br /><sub><b>vcordes79</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=vcordes79" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/fordster78"><img src="https://avatars3.githubusercontent.com/u/27958330?v=4?s=110" width="110px;" alt="fordster78"/><br /><sub><b>fordster78</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=fordster78" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CronKz"><img src="https://avatars0.githubusercontent.com/u/34064225?v=4?s=110" width="110px;" alt="CronKz"/><br /><sub><b>CronKz</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=CronKz" title="Code">💻</a> <a href="#translation-CronKz" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tdb"><img src="https://avatars1.githubusercontent.com/u/585486?v=4?s=110" width="110px;" alt="Tim Bishop"/><br /><sub><b>Tim Bishop</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=tdb" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.seanmcilvenna.com"><img src="https://avatars2.githubusercontent.com/u/5384694?v=4?s=110" width="110px;" alt="Sean McIlvenna"/><br /><sub><b>Sean McIlvenna</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=seanmcilvenna" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/cepacs"><img src="https://avatars3.githubusercontent.com/u/36515590?v=4?s=110" width="110px;" alt="cepacs"/><br /><sub><b>cepacs</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/issues?q=author%3Acepacs" title="Bug reports">🐛</a> <a href="https://github.com/snipe/snipe-it/commits?author=cepacs" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lea-mink"><img src="https://avatars2.githubusercontent.com/u/37537300?v=4?s=110" width="110px;" alt="lea-mink"/><br /><sub><b>lea-mink</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=lea-mink" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hannahtinkler"><img src="https://avatars0.githubusercontent.com/u/7140719?v=4?s=110" width="110px;" alt="Hannah Tinkler"/><br /><sub><b>Hannah Tinkler</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=hannahtinkler" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/doekman"><img src="https://avatars1.githubusercontent.com/u/1086388?v=4?s=110" width="110px;" alt="Doeke Zanstra"/><br /><sub><b>Doeke Zanstra</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=doekman" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.sdhd.nl/"><img src="https://avatars1.githubusercontent.com/u/4325936?v=4?s=110" width="110px;" alt="Djamon Staal"/><br /><sub><b>Djamon Staal</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=SjamonDaal" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EarlRamirez"><img src="https://avatars3.githubusercontent.com/u/12306859?v=4?s=110" width="110px;" alt="Earl Ramirez"/><br /><sub><b>Earl Ramirez</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=EarlRamirez" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RichardRay"><img src="https://avatars2.githubusercontent.com/u/8671456?v=4?s=110" width="110px;" alt="Richard Ray Thomas"/><br /><sub><b>Richard Ray Thomas</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=RichardRay" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.taisun.io/"><img src="https://avatars3.githubusercontent.com/u/1852688?v=4?s=110" width="110px;" alt="Ryan Kuba"/><br /><sub><b>Ryan Kuba</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=thelamer" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ParadoxGuitarist"><img src="https://avatars1.githubusercontent.com/u/6751928?v=4?s=110" width="110px;" alt="Brian Monroe"/><br /><sub><b>Brian Monroe</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=ParadoxGuitarist" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/plexorama"><img src="https://avatars1.githubusercontent.com/u/605167?v=4?s=110" width="110px;" alt="plexorama"/><br /><sub><b>plexorama</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=plexorama" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://tilldeeke.de"><img src="https://avatars2.githubusercontent.com/u/1795149?v=4?s=110" width="110px;" alt="Till Deeke"/><br /><sub><b>Till Deeke</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=tilldeeke" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/5quirrel"><img src="https://avatars0.githubusercontent.com/u/12634129?v=4?s=110" width="110px;" alt="5quirrel"/><br /><sub><b>5quirrel</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=5quirrel" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jasonlshelton"><img src="https://avatars1.githubusercontent.com/u/13071957?v=4?s=110" width="110px;" alt="Jason"/><br /><sub><b>Jason</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=jasonlshelton" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/chemfy"><img src="https://avatars3.githubusercontent.com/u/7128321?v=4?s=110" width="110px;" alt="Antti"/><br /><sub><b>Antti</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=chemfy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DeusMaximus"><img src="https://avatars3.githubusercontent.com/u/10080364?v=4?s=110" width="110px;" alt="DeusMaximus"/><br /><sub><b>DeusMaximus</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=DeusMaximus" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/A-ROYAL"><img src="https://avatars2.githubusercontent.com/u/16384611?v=4?s=110" width="110px;" alt="a-royal"/><br /><sub><b>a-royal</b></sub></a><br /><a href="#translation-A-ROYAL" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/albertoaldrigo"><img src="https://avatars0.githubusercontent.com/u/5358208?v=4?s=110" width="110px;" alt="Alberto Aldrigo"/><br /><sub><b>Alberto Aldrigo</b></sub></a><br /><a href="#translation-albertoaldrigo" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://alex.stanev.org/blog"><img src="https://avatars0.githubusercontent.com/u/1412342?v=4?s=110" width="110px;" alt="Alex Stanev"/><br /><sub><b>Alex Stanev</b></sub></a><br /><a href="#translation-RealEnder" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://devel.itsolution2.de"><img src="https://avatars0.githubusercontent.com/u/177295?v=4?s=110" width="110px;" alt="Andreas Rehm"/><br /><sub><b>Andreas Rehm</b></sub></a><br /><a href="#translation-sirrus" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xelan"><img src="https://avatars0.githubusercontent.com/u/5080535?v=4?s=110" width="110px;" alt="Andreas Erhard"/><br /><sub><b>Andreas Erhard</b></sub></a><br /><a href="#translation-xelan" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/angeldeejay"><img src="https://avatars2.githubusercontent.com/u/142350?v=4?s=110" width="110px;" alt="Andrés Vanegas Jiménez"/><br /><sub><b>Andrés Vanegas Jiménez</b></sub></a><br /><a href="#translation-angeldeejay" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aschiavon91"><img src="https://avatars0.githubusercontent.com/u/3910403?v=4?s=110" width="110px;" alt="Antonio Schiavon"/><br /><sub><b>Antonio Schiavon</b></sub></a><br /><a href="#translation-aschiavon91" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/benunter"><img src="https://avatars0.githubusercontent.com/u/10464547?v=4?s=110" width="110px;" alt="benunter"/><br /><sub><b>benunter</b></sub></a><br /><a href="#translation-benunter" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://catweb24.pl"><img src="https://avatars1.githubusercontent.com/u/5038647?v=4?s=110" width="110px;" alt="Borys Żmuda"/><br /><sub><b>Borys Żmuda</b></sub></a><br /><a href="#translation-rudashi" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/chibacityblues"><img src="https://avatars0.githubusercontent.com/u/5539359?v=4?s=110" width="110px;" alt="chibacityblues"/><br /><sub><b>chibacityblues</b></sub></a><br /><a href="#translation-chibacityblues" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/cwlin0416"><img src="https://avatars1.githubusercontent.com/u/1954830?v=4?s=110" width="110px;" alt="Chien Wei Lin"/><br /><sub><b>Chien Wei Lin</b></sub></a><br /><a href="#translation-cwlin0416" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Againstreality"><img src="https://avatars3.githubusercontent.com/u/11700533?v=4?s=110" width="110px;" alt="Christian Schuster"/><br /><sub><b>Christian Schuster</b></sub></a><br /><a href="#translation-Againstreality" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://chriss.webhostid.com"><img src="https://avatars1.githubusercontent.com/u/4308704?v=4?s=110" width="110px;" alt="Christian Stefanus"/><br /><sub><b>Christian Stefanus</b></sub></a><br /><a href="#translation-kopi-item" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://wxcafe.net"><img src="https://avatars3.githubusercontent.com/u/3009327?v=4?s=110" width="110px;" alt="wxcafé"/><br /><sub><b>wxcafé</b></sub></a><br /><a href="#translation-wxcafe" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dpyroc"><img src="https://avatars3.githubusercontent.com/u/35761525?v=4?s=110" width="110px;" alt="dpyroc"/><br /><sub><b>dpyroc</b></sub></a><br /><a href="#translation-dpyroc" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.friedlmaier.net"><img src="https://avatars1.githubusercontent.com/u/2153639?v=4?s=110" width="110px;" alt="Daniel Friedlmaier"/><br /><sub><b>Daniel Friedlmaier</b></sub></a><br /><a href="#translation-da-friedl" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/danielheene"><img src="https://avatars1.githubusercontent.com/u/2947640?v=4?s=110" width="110px;" alt="Daniel Heene"/><br /><sub><b>Daniel Heene</b></sub></a><br /><a href="#translation-danielheene" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/danielcb"><img src="https://avatars3.githubusercontent.com/u/319022?v=4?s=110" width="110px;" alt="danielcb"/><br /><sub><b>danielcb</b></sub></a><br /><a href="#translation-danielcb" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dominiksenti"><img src="https://avatars3.githubusercontent.com/u/15846537?v=4?s=110" width="110px;" alt="Dominik Senti"/><br /><sub><b>Dominik Senti</b></sub></a><br /><a href="#translation-dominiksenti" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.konectik.com"><img src="https://avatars0.githubusercontent.com/u/25570954?v=4?s=110" width="110px;" alt="Eric Gautheron"/><br /><sub><b>Eric Gautheron</b></sub></a><br /><a href="#translation-EpixFr" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://erlpil.com"><img src="https://avatars1.githubusercontent.com/u/5732623?v=4?s=110" width="110px;" alt="Erlend Pilø"/><br /><sub><b>Erlend Pilø</b></sub></a><br /><a href="#translation-Erlpil" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://fabio.technology"><img src="https://avatars0.githubusercontent.com/u/541832?v=4?s=110" width="110px;" alt="Fabio Rapposelli"/><br /><sub><b>Fabio Rapposelli</b></sub></a><br /><a href="#translation-frapposelli" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/fgbs"><img src="https://avatars2.githubusercontent.com/u/3605240?v=4?s=110" width="110px;" alt="Felipe Barros"/><br /><sub><b>Felipe Barros</b></sub></a><br /><a href="#translation-fgbs" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/possebon"><img src="https://avatars0.githubusercontent.com/u/257745?v=4?s=110" width="110px;" alt="Fernando Possebon"/><br /><sub><b>Fernando Possebon</b></sub></a><br /><a href="#translation-possebon" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gdraque"><img src="https://avatars3.githubusercontent.com/u/2540832?v=4?s=110" width="110px;" alt="gdraque"/><br /><sub><b>gdraque</b></sub></a><br /><a href="#translation-gdraque" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/georgwallisch"><img src="https://avatars0.githubusercontent.com/u/23440381?v=4?s=110" width="110px;" alt="Georg Wallisch"/><br /><sub><b>Georg Wallisch</b></sub></a><br /><a href="#translation-georgwallisch" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jgroblesr85"><img src="https://avatars1.githubusercontent.com/u/9852832?v=4?s=110" width="110px;" alt="Gerardo Robles"/><br /><sub><b>Gerardo Robles</b></sub></a><br /><a href="#translation-jgroblesr85" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://t.me/Gluek"><img src="https://avatars2.githubusercontent.com/u/11082640?v=4?s=110" width="110px;" alt="Gluek"/><br /><sub><b>Gluek</b></sub></a><br /><a href="#translation-mrgluek" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AdnanAbuShahad"><img src="https://avatars0.githubusercontent.com/u/6847946?v=4?s=110" width="110px;" alt="AdnanAbuShahad"/><br /><sub><b>AdnanAbuShahad</b></sub></a><br /><a href="#translation-AdnanAbuShahad" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://hafidzi.my"><img src="https://avatars1.githubusercontent.com/u/3580608?v=4?s=110" width="110px;" alt="Hafidzi My"/><br /><sub><b>Hafidzi My</b></sub></a><br /><a href="#translation-hafidzi" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/fofwisdom"><img src="https://avatars2.githubusercontent.com/u/205521?v=4?s=110" width="110px;" alt="Harim Park"/><br /><sub><b>Harim Park</b></sub></a><br /><a href="#translation-fofwisdom" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.kentsson.se"><img src="https://avatars2.githubusercontent.com/u/3333841?v=4?s=110" width="110px;" alt="Henrik Kentsson"/><br /><sub><b>Henrik Kentsson</b></sub></a><br /><a href="#translation-Kentsson" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/husnulyaqien"><img src="https://avatars0.githubusercontent.com/u/36551034?v=4?s=110" width="110px;" alt="Husnul Yaqien"/><br /><sub><b>Husnul Yaqien</b></sub></a><br /><a href="#translation-husnulyaqien" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://abaalkhail.org"><img src="https://avatars1.githubusercontent.com/u/2372747?v=4?s=110" width="110px;" alt="Ibrahim"/><br /><sub><b>Ibrahim</b></sub></a><br /><a href="#translation-abaalkh" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/igolman"><img src="https://avatars0.githubusercontent.com/u/1389334?v=4?s=110" width="110px;" alt="igolman"/><br /><sub><b>igolman</b></sub></a><br /><a href="#translation-igolman" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/itangiang"><img src="https://avatars1.githubusercontent.com/u/3257070?v=4?s=110" width="110px;" alt="itangiang"/><br /><sub><b>itangiang</b></sub></a><br /><a href="#translation-itangiang" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jarby1211"><img src="https://avatars2.githubusercontent.com/u/14814254?v=4?s=110" width="110px;" alt="jarby1211"/><br /><sub><b>jarby1211</b></sub></a><br /><a href="#translation-jarby1211" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://jwillker.com"><img src="https://avatars3.githubusercontent.com/u/6719357?v=4?s=110" width="110px;" alt="Jhonn Willker"/><br /><sub><b>Jhonn Willker</b></sub></a><br /><a href="#translation-JohnWillker" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/joxelito94"><img src="https://avatars2.githubusercontent.com/u/10983635?v=4?s=110" width="110px;" alt="Jose"/><br /><sub><b>Jose</b></sub></a><br /><a href="#translation-joxelito94" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/laopangzi"><img src="https://avatars0.githubusercontent.com/u/5206122?v=4?s=110" width="110px;" alt="laopangzi"/><br /><sub><b>laopangzi</b></sub></a><br /><a href="#translation-laopangzi" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://usrportage.de"><img src="https://avatars2.githubusercontent.com/u/79707?v=4?s=110" width="110px;" alt="Lars Strojny"/><br /><sub><b>Lars Strojny</b></sub></a><br /><a href="#translation-lstrojny" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://twitter.com/marcosbl"><img src="https://avatars0.githubusercontent.com/u/389801?v=4?s=110" width="110px;" alt="MarcosBL"/><br /><sub><b>MarcosBL</b></sub></a><br /><a href="#translation-MarcosBL" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mariejoyacajes"><img src="https://avatars3.githubusercontent.com/u/35664606?v=4?s=110" width="110px;" alt="marie joy cajes"/><br /><sub><b>marie joy cajes</b></sub></a><br /><a href="#translation-mariejoyacajes" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.markjohansen.dk"><img src="https://avatars2.githubusercontent.com/u/3052816?v=4?s=110" width="110px;" alt="Mark S. Johansen"/><br /><sub><b>Mark S. Johansen</b></sub></a><br /><a href="#translation-msjohansen" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://martinstub.dk"><img src="https://avatars2.githubusercontent.com/u/982885?v=4?s=110" width="110px;" alt="Martin Stub"/><br /><sub><b>Martin Stub</b></sub></a><br /><a href="#translation-stubben" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/meyerf99"><img src="https://avatars2.githubusercontent.com/u/28959963?v=4?s=110" width="110px;" alt="Meyer Flavio"/><br /><sub><b>Meyer Flavio</b></sub></a><br /><a href="#translation-meyerf99" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/MicaelRodrigues"><img src="https://avatars3.githubusercontent.com/u/796443?v=4?s=110" width="110px;" alt="Micael Rodrigues"/><br /><sub><b>Micael Rodrigues</b></sub></a><br /><a href="#translation-MicaelRodrigues" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://rubixy.com/"><img src="https://avatars0.githubusercontent.com/u/10481331?v=4?s=110" width="110px;" alt="Mikael Rasmussen"/><br /><sub><b>Mikael Rasmussen</b></sub></a><br /><a href="#translation-mikaelssen" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/IxFail"><img src="https://avatars1.githubusercontent.com/u/1544552?v=4?s=110" width="110px;" alt="IxFail"/><br /><sub><b>IxFail</b></sub></a><br /><a href="#translation-IxFail" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.mohammedfota.com"><img src="https://avatars3.githubusercontent.com/u/18483118?v=4?s=110" width="110px;" alt="Mohammed Fota"/><br /><sub><b>Mohammed Fota</b></sub></a><br /><a href="#translation-MohammedFota" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/omego"><img src="https://avatars0.githubusercontent.com/u/227080?v=4?s=110" width="110px;" alt="Moayad Alserihi"/><br /><sub><b>Moayad Alserihi</b></sub></a><br /><a href="#translation-omego" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/saymd"><img src="https://avatars0.githubusercontent.com/u/1680266?v=4?s=110" width="110px;" alt="saymd"/><br /><sub><b>saymd</b></sub></a><br /><a href="#translation-saymd" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://nordsken.se"><img src="https://avatars0.githubusercontent.com/u/1826808?v=4?s=110" width="110px;" alt="Patrik Larsson"/><br /><sub><b>Patrik Larsson</b></sub></a><br /><a href="#translation-pooot" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/drcryo"><img src="https://avatars1.githubusercontent.com/u/20584746?v=4?s=110" width="110px;" alt="drcryo"/><br /><sub><b>drcryo</b></sub></a><br /><a href="#translation-drcryo" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/pawel1615"><img src="https://avatars1.githubusercontent.com/u/19408004?v=4?s=110" width="110px;" alt="pawel1615"/><br /><sub><b>pawel1615</b></sub></a><br /><a href="#translation-pawel1615" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/bodrovics"><img src="https://avatars2.githubusercontent.com/u/23340468?v=4?s=110" width="110px;" alt="bodrovics"/><br /><sub><b>bodrovics</b></sub></a><br /><a href="#translation-bodrovics" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/priatna"><img src="https://avatars0.githubusercontent.com/u/3257654?v=4?s=110" width="110px;" alt="priatna"/><br /><sub><b>priatna</b></sub></a><br /><a href="#translation-priatna" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://amayume.net"><img src="https://avatars1.githubusercontent.com/u/5358374?v=4?s=110" width="110px;" alt="Fan Jiang"/><br /><sub><b>Fan Jiang</b></sub></a><br /><a href="#translation-ProfFan" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ragnarcx"><img src="https://avatars1.githubusercontent.com/u/22555451?v=4?s=110" width="110px;" alt="ragnarcx"/><br /><sub><b>ragnarcx</b></sub></a><br /><a href="#translation-ragnarcx" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.reinvanhaaren.nl/"><img src="https://avatars2.githubusercontent.com/u/18654582?v=4?s=110" width="110px;" alt="Rein van Haaren"/><br /><sub><b>Rein van Haaren</b></sub></a><br /><a href="#translation-reinvanhaaren" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://dheche.songolimo.net"><img src="https://avatars1.githubusercontent.com/u/386672?v=4?s=110" width="110px;" alt="Teguh Dwicaksana"/><br /><sub><b>Teguh Dwicaksana</b></sub></a><br /><a href="#translation-dheche" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/FRaccie"><img src="https://avatars2.githubusercontent.com/u/2572552?v=4?s=110" width="110px;" alt="fraccie"/><br /><sub><b>fraccie</b></sub></a><br /><a href="#translation-FRaccie" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/vinzruzell"><img src="https://avatars0.githubusercontent.com/u/35182720?v=4?s=110" width="110px;" alt="vinzruzell"/><br /><sub><b>vinzruzell</b></sub></a><br /><a href="#translation-vinzruzell" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://kevinaustin.com"><img src="https://avatars1.githubusercontent.com/u/7883603?v=4?s=110" width="110px;" alt="Kevin Austin"/><br /><sub><b>Kevin Austin</b></sub></a><br /><a href="#translation-vipsystem" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://azuraweb.xyz"><img src="https://avatars3.githubusercontent.com/u/3861828?v=4?s=110" width="110px;" alt="Wira Sandy"/><br /><sub><b>Wira Sandy</b></sub></a><br /><a href="#translation-wira-sandy" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/GrayHoax"><img src="https://avatars2.githubusercontent.com/u/8663789?v=4?s=110" width="110px;" alt="Илья"/><br /><sub><b>Илья</b></sub></a><br /><a href="#translation-GrayHoax" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/godusevpn"><img src="https://avatars3.githubusercontent.com/u/30119111?v=4?s=110" width="110px;" alt="GodUseVPN"/><br /><sub><b>GodUseVPN</b></sub></a><br /><a href="#translation-godusevpn" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EngrZhou"><img src="https://avatars1.githubusercontent.com/u/745576?v=4?s=110" width="110px;" alt="周周"/><br /><sub><b>周周</b></sub></a><br /><a href="#translation-EngrZhou" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/takuy"><img src="https://avatars3.githubusercontent.com/u/1631095?v=4?s=110" width="110px;" alt="Sam"/><br /><sub><b>Sam</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=takuy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.illisian.com.au"><img src="https://avatars1.githubusercontent.com/u/264022?v=4?s=110" width="110px;" alt="Azerothian"/><br /><sub><b>Azerothian</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Azerothian" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://macfoo.wordpress.com/"><img src="https://avatars1.githubusercontent.com/u/4930051?v=4?s=110" width="110px;" alt="Wes Hulette"/><br /><sub><b>Wes Hulette</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=jwhulette" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/patrict"><img src="https://avatars0.githubusercontent.com/u/8134591?v=4?s=110" width="110px;" alt="patrict"/><br /><sub><b>patrict</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=patrict" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/VELIKII-DIVAN"><img src="https://avatars3.githubusercontent.com/u/2611616?v=4?s=110" width="110px;" alt="Dmitriy Minaev"/><br /><sub><b>Dmitriy Minaev</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=VELIKII-DIVAN" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/liquidhorse"><img src="https://avatars0.githubusercontent.com/u/5132245?v=4?s=110" width="110px;" alt="liquidhorse"/><br /><sub><b>liquidhorse</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=liquidhorse" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://seld.be/"><img src="https://avatars1.githubusercontent.com/u/183678?v=4?s=110" width="110px;" alt="Jordi Boggiano"/><br /><sub><b>Jordi Boggiano</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Seldaek" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/inietov"><img src="https://avatars0.githubusercontent.com/u/653557?v=4?s=110" width="110px;" alt="Ivan Nieto"/><br /><sub><b>Ivan Nieto</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=inietov" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/benrubson"><img src="https://avatars2.githubusercontent.com/u/6764151?v=4?s=110" width="110px;" alt="Ben RUBSON"/><br /><sub><b>Ben RUBSON</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=benrubson" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/NMathar"><img src="https://avatars2.githubusercontent.com/u/8554558?v=4?s=110" width="110px;" alt="NMathar"/><br /><sub><b>NMathar</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=NMathar" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/smb"><img src="https://avatars1.githubusercontent.com/u/139566?v=4?s=110" width="110px;" alt="Steffen"/><br /><sub><b>Steffen</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=smb" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Sxderp"><img src="https://avatars0.githubusercontent.com/u/6609453?v=4?s=110" width="110px;" alt="Sxderp"/><br /><sub><b>Sxderp</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Sxderp" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/fanta8897"><img src="https://avatars1.githubusercontent.com/u/4807843?v=4?s=110" width="110px;" alt="fanta8897"/><br /><sub><b>fanta8897</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=fanta8897" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://andreybolonin.com/phpconsulting/"><img src="https://avatars2.githubusercontent.com/u/2576509?v=4?s=110" width="110px;" alt="Andrey Bolonin"/><br /><sub><b>Andrey Bolonin</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=andreybolonin" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.shinayoshi.net/"><img src="https://avatars3.githubusercontent.com/u/2173307?v=4?s=110" width="110px;" alt="shinayoshi"/><br /><sub><b>shinayoshi</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=shinayoshi" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/reuser"><img src="https://avatars3.githubusercontent.com/u/2130159?v=4?s=110" width="110px;" alt="Hubert"/><br /><sub><b>Hubert</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=reuser" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://brashear.me"><img src="https://avatars0.githubusercontent.com/u/6865789?v=4?s=110" width="110px;" alt="KeenRivals"/><br /><sub><b>KeenRivals</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=KeenRivals" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/omyno"><img src="https://avatars3.githubusercontent.com/u/2902513?v=4?s=110" width="110px;" alt="omyno"/><br /><sub><b>omyno</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=omyno" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jackka"><img src="https://avatars1.githubusercontent.com/u/6271335?v=4?s=110" width="110px;" alt="Evgeny"/><br /><sub><b>Evgeny</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=jackka" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://digitalist.se"><img src="https://avatars2.githubusercontent.com/u/1169963?v=4?s=110" width="110px;" alt="Colin Campbell"/><br /><sub><b>Colin Campbell</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=colin-campbell" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lubo"><img src="https://avatars3.githubusercontent.com/u/2872098?v=4?s=110" width="110px;" alt="Ľubomír Kučera"/><br /><sub><b>Ľubomír Kučera</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=lubo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.sourceguru.net"><img src="https://avatars3.githubusercontent.com/u/570639?v=4?s=110" width="110px;" alt="Martin Meredith"/><br /><sub><b>Martin Meredith</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Mezzle" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/timothyfarmer"><img src="https://avatars1.githubusercontent.com/u/7632599?v=4?s=110" width="110px;" alt="Tim Farmer"/><br /><sub><b>Tim Farmer</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=timothyfarmer" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mskrip"><img src="https://avatars0.githubusercontent.com/u/17459600?v=4?s=110" width="110px;" alt="Marián Skrip"/><br /><sub><b>Marián Skrip</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=mskrip" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Godmartinz"><img src="https://avatars2.githubusercontent.com/u/47435081?v=4?s=110" width="110px;" alt="Godfrey Martinez"/><br /><sub><b>Godfrey Martinez</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Godmartinz" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/bigtreeEdo"><img src="https://avatars1.githubusercontent.com/u/2075128?v=4?s=110" width="110px;" alt="bigtreeEdo"/><br /><sub><b>bigtreeEdo</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=bigtreeEdo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://colinmcneil.me/"><img src="https://avatars0.githubusercontent.com/u/5000430?v=4?s=110" width="110px;" alt="Colin McNeil"/><br /><sub><b>Colin McNeil</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=ColinMcNeil" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JoKneeMo"><img src="https://avatars0.githubusercontent.com/u/421625?v=4?s=110" width="110px;" alt="JoKneeMo"/><br /><sub><b>JoKneeMo</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=JoKneeMo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.redbridge.se"><img src="https://avatars0.githubusercontent.com/u/54849013?v=4?s=110" width="110px;" alt="Joshi"/><br /><sub><b>Joshi</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=joshi-redbridge" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/anthonypburns"><img src="https://avatars2.githubusercontent.com/u/15731458?v=4?s=110" width="110px;" alt="Anthony Burns"/><br /><sub><b>Anthony Burns</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=anthonypburns" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/johnson-yi"><img src="https://avatars1.githubusercontent.com/u/63399474?v=4?s=110" width="110px;" alt="johnson-yi"/><br /><sub><b>johnson-yi</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=johnson-yi" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://tangentmc.net"><img src="https://avatars1.githubusercontent.com/u/1862720?v=4?s=110" width="110px;" alt="Sanjay Govind"/><br /><sub><b>Sanjay Govind</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=sanjay900" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://peter.upfold.org.uk/"><img src="https://avatars0.githubusercontent.com/u/1255375?v=4?s=110" width="110px;" alt="Peter Upfold"/><br /><sub><b>Peter Upfold</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=PeterUpfold" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jbiel"><img src="https://avatars2.githubusercontent.com/u/961717?v=4?s=110" width="110px;" alt="Jared Biel"/><br /><sub><b>Jared Biel</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=jbiel" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dampfklon"><img src="https://avatars1.githubusercontent.com/u/1733625?v=4?s=110" width="110px;" alt="Dampfklon"/><br /><sub><b>Dampfklon</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=dampfklon" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://communityclosing.com"><img src="https://avatars2.githubusercontent.com/u/52973156?v=4?s=110" width="110px;" alt="Charles Hamilton"/><br /><sub><b>Charles Hamilton</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=chamilton-ccn" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/giannello"><img src="https://avatars.githubusercontent.com/u/551789?v=4?s=110" width="110px;" alt="Giuseppe Iannello"/><br /><sub><b>Giuseppe Iannello</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=giannello" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.peterdavehello.org/"><img src="https://avatars.githubusercontent.com/u/3691490?v=4?s=110" width="110px;" alt="Peter Dave Hello"/><br /><sub><b>Peter Dave Hello</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=PeterDaveHello" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sigmoidal"><img src="https://avatars.githubusercontent.com/u/6106332?v=4?s=110" width="110px;" alt="sigmoidal"/><br /><sub><b>sigmoidal</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=sigmoidal" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/phenixdotnet"><img src="https://avatars.githubusercontent.com/u/2082554?v=4?s=110" width="110px;" alt="Vincent Lainé"/><br /><sub><b>Vincent Lainé</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=phenixdotnet" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.lucas-pless.com"><img src="https://avatars.githubusercontent.com/u/1943040?v=4?s=110" width="110px;" alt="Lucas Pleß"/><br /><sub><b>Lucas Pleß</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=derlucas" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://twitter.com/iansltx"><img src="https://avatars.githubusercontent.com/u/472804?v=4?s=110" width="110px;" alt="Ian Littman"/><br /><sub><b>Ian Littman</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=iansltx" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/PauloLuna"><img src="https://avatars.githubusercontent.com/u/3519029?v=4?s=110" width="110px;" alt="João Paulo"/><br /><sub><b>João Paulo</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=PauloLuna" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThoBur"><img src="https://avatars.githubusercontent.com/u/70443365?v=4?s=110" width="110px;" alt="ThoBur"/><br /><sub><b>ThoBur</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=ThoBur" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://phpprofi.ru/"><img src="https://avatars.githubusercontent.com/u/1972329?v=4?s=110" width="110px;" alt="Alexander Chibrikin"/><br /><sub><b>Alexander Chibrikin</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=alek13" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/winstan"><img src="https://avatars.githubusercontent.com/u/438332?v=4?s=110" width="110px;" alt="Anthony Winstanley"/><br /><sub><b>Anthony Winstanley</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=winstan" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/fashberg"><img src="https://avatars.githubusercontent.com/u/3075214?v=4?s=110" width="110px;" alt="Folke"/><br /><sub><b>Folke</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=fashberg" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/benwa"><img src="https://avatars.githubusercontent.com/u/1351571?v=4?s=110" width="110px;" alt="Bennett Blodinger"/><br /><sub><b>Bennett Blodinger</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=benwa" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://nmc.dev"><img src="https://avatars.githubusercontent.com/u/2974631?v=4?s=110" width="110px;" alt="NMC"/><br /><sub><b>NMC</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=ncareau" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andres-baller"><img src="https://avatars.githubusercontent.com/u/52182449?v=4?s=110" width="110px;" alt="andres-baller"/><br /><sub><b>andres-baller</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=andres-baller" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sean-borg"><img src="https://avatars.githubusercontent.com/u/67109348?v=4?s=110" width="110px;" alt="sean-borg"/><br /><sub><b>sean-borg</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=sean-borg" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EDVLeer"><img src="https://avatars.githubusercontent.com/u/32170051?v=4?s=110" width="110px;" alt="EDVLeer"/><br /><sub><b>EDVLeer</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=EDVLeer" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Kurokat"><img src="https://avatars.githubusercontent.com/u/23075196?v=4?s=110" width="110px;" alt="Kurokat"/><br /><sub><b>Kurokat</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Kurokat" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://www.kevinkoellmann.de"><img src="https://avatars.githubusercontent.com/u/915514?v=4?s=110" width="110px;" alt="Kevin Köllmann"/><br /><sub><b>Kevin Köllmann</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=koelle25" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sw-mreyes"><img src="https://avatars.githubusercontent.com/u/49025941?v=4?s=110" width="110px;" alt="sw-mreyes"/><br /><sub><b>sw-mreyes</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=sw-mreyes" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://pittet.ca"><img src="https://avatars.githubusercontent.com/u/70129?v=4?s=110" width="110px;" alt="Joel Pittet"/><br /><sub><b>Joel Pittet</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=joelpittet" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://elyscape.com"><img src="https://avatars.githubusercontent.com/u/792695?v=4?s=110" width="110px;" alt="Eli Young"/><br /><sub><b>Eli Young</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=elyscape" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/raelldottin"><img src="https://avatars.githubusercontent.com/u/317015?v=4?s=110" width="110px;" alt="Raell Dottin"/><br /><sub><b>Raell Dottin</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=raelldottin" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/misilot"><img src="https://avatars.githubusercontent.com/u/1446856?v=4?s=110" width="110px;" alt="Tom Misilo"/><br /><sub><b>Tom Misilo</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=misilot" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://david.davenne.be"><img src="https://avatars.githubusercontent.com/u/4496300?v=4?s=110" width="110px;" alt="David Davenne"/><br /><sub><b>David Davenne</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=JuustoMestari" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://markstenglein.com"><img src="https://avatars.githubusercontent.com/u/9255772?v=4?s=110" width="110px;" alt="Mark Stenglein"/><br /><sub><b>Mark Stenglein</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=ocelotsloth" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ajsy"><img src="https://avatars.githubusercontent.com/u/35658596?v=4?s=110" width="110px;" alt="ajsy"/><br /><sub><b>ajsy</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=ajsy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/t3easy"><img src="https://avatars.githubusercontent.com/u/3628035?v=4?s=110" width="110px;" alt="Jan Kiesewetter"/><br /><sub><b>Jan Kiesewetter</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=t3easy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Tetrachloromethane250"><img src="https://avatars.githubusercontent.com/u/79449630?v=4?s=110" width="110px;" alt="Tetrachloromethane250"/><br /><sub><b>Tetrachloromethane250</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Tetrachloromethane250" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.kajes.se/"><img src="https://avatars.githubusercontent.com/u/22004482?v=4?s=110" width="110px;" alt="Lars Kajes"/><br /><sub><b>Lars Kajes</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=kajes" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Joly0"><img src="https://avatars.githubusercontent.com/u/13993216?v=4?s=110" width="110px;" alt="Joly0"/><br /><sub><b>Joly0</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Joly0" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/limeless"><img src="https://avatars.githubusercontent.com/u/1501022?v=4?s=110" width="110px;" alt="theburger"/><br /><sub><b>theburger</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=limeless" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/deivishome"><img src="https://avatars.githubusercontent.com/u/36065681?v=4?s=110" width="110px;" alt="David Valin Alonso"/><br /><sub><b>David Valin Alonso</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=deivishome" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andreaci"><img src="https://avatars.githubusercontent.com/u/8290389?v=4?s=110" width="110px;" alt="andreaci"/><br /><sub><b>andreaci</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=andreaci" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.jellesebreghts.be"><img src="https://avatars.githubusercontent.com/u/1828542?v=4?s=110" width="110px;" alt="Jelle Sebreghts"/><br /><sub><b>Jelle Sebreghts</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Jelle-S" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Skywalker-11"><img src="https://avatars.githubusercontent.com/u/11180862?v=4?s=110" width="110px;" alt="Michael Pietsch"/><br /><sub><b>Michael Pietsch</b></sub></a><br /></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sh1hab"><img src="https://avatars.githubusercontent.com/u/22068886?v=4?s=110" width="110px;" alt="Masudul Haque Shihab"/><br /><sub><b>Masudul Haque Shihab</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=sh1hab" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.freedomdive.com/"><img src="https://avatars.githubusercontent.com/u/16099942?v=4?s=110" width="110px;" alt="Supapong Areeprasertkul"/><br /><sub><b>Supapong Areeprasertkul</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=zybersup" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/psarossy"><img src="https://avatars.githubusercontent.com/u/207358?v=4?s=110" width="110px;" alt="Peter Sarossy"/><br /><sub><b>Peter Sarossy</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=psarossy" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/nepella"><img src="https://avatars.githubusercontent.com/u/11823649?v=4?s=110" width="110px;" alt="Renee Margaret McConahy"/><br /><sub><b>Renee Margaret McConahy</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=nepella" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JohnnyPicnic"><img src="https://avatars.githubusercontent.com/u/5553884?v=4?s=110" width="110px;" alt="JohnnyPicnic"/><br /><sub><b>JohnnyPicnic</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=JohnnyPicnic" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/markbrule"><img src="https://avatars.githubusercontent.com/u/8799594?v=4?s=110" width="110px;" alt="markbrule"/><br /><sub><b>markbrule</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=markbrule" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mikecmpbll"><img src="https://avatars.githubusercontent.com/u/1962801?v=4?s=110" width="110px;" alt="Mike Campbell"/><br /><sub><b>Mike Campbell</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=mikecmpbll" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tbrconnect"><img src="https://avatars.githubusercontent.com/u/11973217?v=4?s=110" width="110px;" alt="tbrconnect"/><br /><sub><b>tbrconnect</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=tbrconnect" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kcoyo"><img src="https://avatars.githubusercontent.com/u/12447225?v=4?s=110" width="110px;" alt="kcoyo"/><br /><sub><b>kcoyo</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=kcoyo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://travismiller.com/"><img src="https://avatars.githubusercontent.com/u/494017?v=4?s=110" width="110px;" alt="Travis Miller"/><br /><sub><b>Travis Miller</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=travismiller" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Delta5"><img src="https://avatars.githubusercontent.com/u/1975640?v=4?s=110" width="110px;" alt="Evan Taylor"/><br /><sub><b>Evan Taylor</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Delta5" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/PetriAsi"><img src="https://avatars.githubusercontent.com/u/8735148?v=4?s=110" width="110px;" alt="Petri Asikainen"/><br /><sub><b>Petri Asikainen</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=PetriAsi" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/derdeagle"><img src="https://avatars.githubusercontent.com/u/11424540?v=4?s=110" width="110px;" alt="derdeagle"/><br /><sub><b>derdeagle</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=derdeagle" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://wh0rd.org/"><img src="https://avatars.githubusercontent.com/u/176950?v=4?s=110" width="110px;" alt="Mike Frysinger"/><br /><sub><b>Mike Frysinger</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=vapier" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AL4AL"><img src="https://avatars.githubusercontent.com/u/22044358?v=4?s=110" width="110px;" alt="ALPHA"/><br /><sub><b>ALPHA</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=AL4AL" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.ifern.de"><img src="https://avatars.githubusercontent.com/u/1042587?v=4?s=110" width="110px;" alt="FliegenKLATSCH"/><br /><sub><b>FliegenKLATSCH</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=FliegenKLATSCH" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jerm"><img src="https://avatars.githubusercontent.com/u/442138?v=4?s=110" width="110px;" alt="Jeremy Price"/><br /><sub><b>Jeremy Price</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=jerm" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Toreg87"><img src="https://avatars.githubusercontent.com/u/84392209?v=4?s=110" width="110px;" alt="Toreg87"/><br /><sub><b>Toreg87</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Toreg87" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Computroniks"><img src="https://avatars.githubusercontent.com/u/67638596?v=4?s=110" width="110px;" alt="Matthew Nickson"/><br /><sub><b>Matthew Nickson</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Computroniks" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://jethron.id.au"><img src="https://avatars.githubusercontent.com/u/1646397?v=4?s=110" width="110px;" alt="Jethro Nederhof"/><br /><sub><b>Jethro Nederhof</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=jethron" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/01ste02"><img src="https://avatars.githubusercontent.com/u/23289826?v=4?s=110" width="110px;" alt="Oskar Stenberg"/><br /><sub><b>Oskar Stenberg</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=01ste02" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Robert-Azelis"><img src="https://avatars.githubusercontent.com/u/82208283?v=4?s=110" width="110px;" alt="Robert-Azelis"/><br /><sub><b>Robert-Azelis</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Robert-Azelis" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/alwism"><img src="https://avatars.githubusercontent.com/u/60648387?v=4?s=110" width="110px;" alt="Alexander William Smith"/><br /><sub><b>Alexander William Smith</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=alwism" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.leitwerk.de/"><img src="https://avatars.githubusercontent.com/u/24418301?v=4?s=110" width="110px;" alt="LEITWERK AG"/><br /><sub><b>LEITWERK AG</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=leitwerk-ag" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://www.aboutcher.co.uk"><img src="https://avatars.githubusercontent.com/u/1911435?v=4?s=110" width="110px;" alt="Adam"/><br /><sub><b>Adam</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=adamboutcher" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://snksrv.com"><img src="https://avatars.githubusercontent.com/u/16104273?v=4?s=110" width="110px;" alt="Ian"/><br /><sub><b>Ian</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=sneak-it" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://blog.bestlong.idv.tw/"><img src="https://avatars.githubusercontent.com/u/4023909?v=4?s=110" width="110px;" alt="Shao Yu-Lung (Allen)"/><br /><sub><b>Shao Yu-Lung (Allen)</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=bestlong" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Haxatron"><img src="https://avatars.githubusercontent.com/u/76475453?v=4?s=110" width="110px;" alt="Haxatron"/><br /><sub><b>Haxatron</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Haxatron" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/PlaneNuts"><img src="https://avatars.githubusercontent.com/u/88776392?v=4?s=110" width="110px;" alt="PlaneNuts"/><br /><sub><b>PlaneNuts</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=PlaneNuts" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://bjcpgd.cias.rit.edu"><img src="https://avatars.githubusercontent.com/u/3842948?v=4?s=110" width="110px;" alt="Bradley Coudriet"/><br /><sub><b>Bradley Coudriet</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=exula" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://daltondur.st"><img src="https://avatars.githubusercontent.com/u/21966173?v=4?s=110" width="110px;" alt="Dalton Durst"/><br /><sub><b>Dalton Durst</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=UniversalSuperBox" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://adagiohealth.org"><img src="https://avatars.githubusercontent.com/u/38761237?v=4?s=110" width="110px;" alt="Alex Janes"/><br /><sub><b>Alex Janes</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=adagioajanes" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/nuraeil"><img src="https://avatars.githubusercontent.com/u/32387849?v=4?s=110" width="110px;" alt="Nuraeil"/><br /><sub><b>Nuraeil</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=nuraeil" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/TenOfTens"><img src="https://avatars.githubusercontent.com/u/48162670?v=4?s=110" width="110px;" alt="TenOfTens"/><br /><sub><b>TenOfTens</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=TenOfTens" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://ditisjens.be/"><img src="https://avatars.githubusercontent.com/u/9415391?v=4?s=110" width="110px;" alt="waffle"/><br /><sub><b>waffle</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=insert-waffle" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/QveenSi"><img src="https://avatars.githubusercontent.com/u/19945501?v=4?s=110" width="110px;" alt="Yevhenii Huzii"/><br /><sub><b>Yevhenii Huzii</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=QveenSi" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/veenone"><img src="https://avatars.githubusercontent.com/u/3839381?v=4?s=110" width="110px;" alt="Achmad Fienan Rahardianto"/><br /><sub><b>Achmad Fienan Rahardianto</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=veenone" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/QveenSi"><img src="https://avatars.githubusercontent.com/u/19945501?v=4?s=110" width="110px;" alt="Yevhenii Huzii"/><br /><sub><b>Yevhenii Huzii</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=QveenSi" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/chrisweirich"><img src="https://avatars.githubusercontent.com/u/97299851?v=4?s=110" width="110px;" alt="Christian Weirich"/><br /><sub><b>Christian Weirich</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=chrisweirich" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/denzfarid"><img src="https://avatars.githubusercontent.com/u/1294403?v=4?s=110" width="110px;" alt="denzfarid"/><br /><sub><b>denzfarid</b></sub></a><br /></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ntbutler-nbcs"><img src="https://avatars.githubusercontent.com/u/94018771?v=4?s=110" width="110px;" alt="ntbutler-nbcs"/><br /><sub><b>ntbutler-nbcs</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=ntbutler-nbcs" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://naveensrinivasan.dev"><img src="https://avatars.githubusercontent.com/u/172697?v=4?s=110" width="110px;" alt="Naveen"/><br /><sub><b>Naveen</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=naveensrinivasan" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mikeroq"><img src="https://avatars.githubusercontent.com/u/55674383?v=4?s=110" width="110px;" alt="Mike Roquemore"/><br /><sub><b>Mike Roquemore</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=mikeroq" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/reederda"><img src="https://avatars.githubusercontent.com/u/7991086?v=4?s=110" width="110px;" alt="Daniel Reeder"/><br /><sub><b>Daniel Reeder</b></sub></a><br /><a href="#translation-reederda" title="Translation">🌍</a> <a href="#translation-reederda" title="Translation">🌍</a> <a href="https://github.com/snipe/snipe-it/commits?author=reederda" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/vickyjaura183"><img src="https://avatars.githubusercontent.com/u/109422491?v=4?s=110" width="110px;" alt="vickyjaura183"/><br /><sub><b>vickyjaura183</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=vickyjaura183" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/julian-piehl"><img src="https://avatars.githubusercontent.com/u/32363424?v=4?s=110" width="110px;" alt="Peace"/><br /><sub><b>Peace</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=julian-piehl" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kylegordon"><img src="https://avatars.githubusercontent.com/u/231528?v=4?s=110" width="110px;" alt="Kyle Gordon"/><br /><sub><b>Kyle Gordon</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=kylegordon" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.bfh.ch"><img src="https://avatars.githubusercontent.com/u/53009155?v=4?s=110" width="110px;" alt="Katharina Drexel"/><br /><sub><b>Katharina Drexel</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=sunflowerbofh" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://david.sferruzza.fr/"><img src="https://avatars.githubusercontent.com/u/1931963?v=4?s=110" width="110px;" alt="David Sferruzza"/><br /><sub><b>David Sferruzza</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=dsferruzza" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rnelsonee"><img src="https://avatars.githubusercontent.com/u/19511639?v=4?s=110" width="110px;" alt="Rick Nelson"/><br /><sub><b>Rick Nelson</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=rnelsonee" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/BasO12"><img src="https://avatars.githubusercontent.com/u/94169344?v=4?s=110" width="110px;" alt="BasO12"/><br /><sub><b>BasO12</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=BasO12" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Vautia"><img src="https://avatars.githubusercontent.com/u/111710123?v=4?s=110" width="110px;" alt="Vautia"/><br /><sub><b>Vautia</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Vautia" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://www.littlehart.net/atthekeyboard"><img src="https://avatars.githubusercontent.com/u/28321?v=4?s=110" width="110px;" alt="Chris Hartjes"/><br /><sub><b>Chris Hartjes</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=chartjes" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/geo-chen"><img src="https://avatars.githubusercontent.com/u/2404584?v=4?s=110" width="110px;" alt="geo-chen"/><br /><sub><b>geo-chen</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=geo-chen" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/nh314"><img src="https://avatars.githubusercontent.com/u/6006620?v=4?s=110" width="110px;" alt="Phan Nguyen"/><br /><sub><b>Phan Nguyen</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=nh314" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/StarlessNights"><img src="https://avatars.githubusercontent.com/u/115993812?v=4?s=110" width="110px;" alt="Iisakki Jaakkola"/><br /><sub><b>Iisakki Jaakkola</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=StarlessNights" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://bandism.net/"><img src="https://avatars.githubusercontent.com/u/22633385?v=4?s=110" width="110px;" alt="Ikko Ashimine"/><br /><sub><b>Ikko Ashimine</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=eltociear" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lukasfehling"><img src="https://avatars.githubusercontent.com/u/56871540?v=4?s=110" width="110px;" alt="Lukas Fehling"/><br /><sub><b>Lukas Fehling</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=lukasfehling" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/fernando-almeida"><img src="https://avatars.githubusercontent.com/u/1975990?v=4?s=110" width="110px;" alt="Fernando Almeida"/><br /><sub><b>Fernando Almeida</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=fernando-almeida" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/akemidx"><img src="https://avatars.githubusercontent.com/u/116301219?v=4?s=110" width="110px;" alt="akemidx"/><br /><sub><b>akemidx</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=akemidx" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://oguz.site"><img src="https://avatars.githubusercontent.com/u/144778?v=4?s=110" width="110px;" alt="Oguz Bilgic"/><br /><sub><b>Oguz Bilgic</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=oguzbilgic" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/scoo73r"><img src="https://avatars.githubusercontent.com/u/9262438?v=4?s=110" width="110px;" alt="Scooter Crawford"/><br /><sub><b>Scooter Crawford</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=scoo73r" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/subdriven"><img src="https://avatars.githubusercontent.com/u/5957345?v=4?s=110" width="110px;" alt="subdriven"/><br /><sub><b>subdriven</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=subdriven" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AndrewSav"><img src="https://avatars.githubusercontent.com/u/658865?v=4?s=110" width="110px;" alt="Andrew Savinykh"/><br /><sub><b>Andrew Savinykh</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=AndrewSav" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://kenchan0130.github.io"><img src="https://avatars.githubusercontent.com/u/1155067?v=4?s=110" width="110px;" alt="Tadayuki Onishi"/><br /><sub><b>Tadayuki Onishi</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=kenchan0130" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/floschoepfer"><img src="https://avatars.githubusercontent.com/u/112496896?v=4?s=110" width="110px;" alt="Florian"/><br /><sub><b>Florian</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=floschoepfer" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://spencerlong.com"><img src="https://avatars.githubusercontent.com/u/7305753?v=4?s=110" width="110px;" alt="Spencer Long"/><br /><sub><b>Spencer Long</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=spencerrlongg" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/marcusmoore"><img src="https://avatars.githubusercontent.com/u/1141514?v=4?s=110" width="110px;" alt="Marcus Moore"/><br /><sub><b>Marcus Moore</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=marcusmoore" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Mezzle"><img src="https://avatars.githubusercontent.com/u/570639?v=4?s=110" width="110px;" alt="Martin Meredith"/><br /><sub><b>Martin Meredith</b></sub></a><br /></td>
<td align="center" valign="top" width="14.28%"><a href="http://dboth.de"><img src="https://avatars.githubusercontent.com/u/5731963?v=4?s=110" width="110px;" alt="dboth"/><br /><sub><b>dboth</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=dboth" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zacharyfleck"><img src="https://avatars.githubusercontent.com/u/87536651?v=4?s=110" width="110px;" alt="Zachary Fleck"/><br /><sub><b>Zachary Fleck</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=zacharyfleck" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/vikaas-cyper"><img src="https://avatars.githubusercontent.com/u/74609912?v=4?s=110" width="110px;" alt="VIKAAS-A"/><br /><sub><b>VIKAAS-A</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=vikaas-cyper" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ak-piracha"><img src="https://avatars.githubusercontent.com/u/88882041?v=4?s=110" width="110px;" alt="Abdul Kareem"/><br /><sub><b>Abdul Kareem</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=ak-piracha" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/NojoudAlshehri"><img src="https://avatars.githubusercontent.com/u/111287779?v=4?s=110" width="110px;" alt="NojoudAlshehri"/><br /><sub><b>NojoudAlshehri</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=NojoudAlshehri" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/stefanstidlffg"><img src="https://avatars.githubusercontent.com/u/54367449?v=4?s=110" width="110px;" alt="Stefan Stidl"/><br /><sub><b>Stefan Stidl</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=stefanstidlffg" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/qay21"><img src="https://avatars.githubusercontent.com/u/87803479?v=4?s=110" width="110px;" alt="Quentin Aymard"/><br /><sub><b>Quentin Aymard</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=qay21" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/cram42"><img src="https://avatars.githubusercontent.com/u/5396871?v=4?s=110" width="110px;" alt="Grant Le Roux"/><br /><sub><b>Grant Le Roux</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=cram42" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://@singrity"><img src="https://avatars.githubusercontent.com/u/58479551?v=4?s=110" width="110px;" alt="Bogdan"/><br /><sub><b>Bogdan</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Singrity" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mmanjos"><img src="https://avatars.githubusercontent.com/u/3483684?v=4?s=110" width="110px;" alt="mmanjos"/><br /><sub><b>mmanjos</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=mmanjos" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://azooz2014.github.io/"><img src="https://avatars.githubusercontent.com/u/7429229?v=4?s=110" width="110px;" alt="Abdelaziz Faki"/><br /><sub><b>Abdelaziz Faki</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Azooz2014" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/bilias"><img src="https://avatars.githubusercontent.com/u/47315739?v=4?s=110" width="110px;" alt="bilias"/><br /><sub><b>bilias</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=bilias" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/coach1988"><img src="https://avatars.githubusercontent.com/u/2565989?v=4?s=110" width="110px;" alt="coach1988"/><br /><sub><b>coach1988</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=coach1988" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mauro-miatello"><img src="https://avatars.githubusercontent.com/u/11910225?v=4?s=110" width="110px;" alt="MrM"/><br /><sub><b>MrM</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=mauro-miatello" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/koiakoia"><img src="https://avatars.githubusercontent.com/u/60405354?v=4?s=110" width="110px;" alt="koiakoia"/><br /><sub><b>koiakoia</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=koiakoia" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mustafa-online"><img src="https://avatars.githubusercontent.com/u/5323832?v=4?s=110" width="110px;" alt="Mustafa Online"/><br /><sub><b>Mustafa Online</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=mustafa-online" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franceslui"><img src="https://avatars.githubusercontent.com/u/104601439?v=4?s=110" width="110px;" alt="franceslui"/><br /><sub><b>franceslui</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=franceslui" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Q4kK"><img src="https://avatars.githubusercontent.com/u/125313163?v=4?s=110" width="110px;" alt="Q4kK"/><br /><sub><b>Q4kK</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Q4kK" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/squintfox"><img src="https://avatars.githubusercontent.com/u/55590532?v=4?s=110" width="110px;" alt="squintfox"/><br /><sub><b>squintfox</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=squintfox" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jeffclay"><img src="https://avatars.githubusercontent.com/u/1380084?v=4?s=110" width="110px;" alt="Jeff Clay"/><br /><sub><b>Jeff Clay</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=jeffclay" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/PP-JN-RL"><img src="https://avatars.githubusercontent.com/u/52716446?v=4?s=110" width="110px;" alt="Phil J R"/><br /><sub><b>Phil J R</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=PP-JN-RL" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.corelight.com/"><img src="https://avatars.githubusercontent.com/u/1496725?v=4?s=110" width="110px;" alt="i_virus"/><br /><sub><b>i_virus</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=chandanchowdhury" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gitgrimbo"><img src="https://avatars.githubusercontent.com/u/1020541?v=4?s=110" width="110px;" alt="Paul Grime"/><br /><sub><b>Paul Grime</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=gitgrimbo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://leeporte.co.uk"><img src="https://avatars.githubusercontent.com/u/922815?v=4?s=110" width="110px;" alt="Lee Porte"/><br /><sub><b>Lee Porte</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=LeePorte" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/bryanlopezinc"><img src="https://avatars.githubusercontent.com/u/23613427?v=4?s=110" width="110px;" alt="BRYAN "/><br /><sub><b>BRYAN </b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=bryanlopezinc" title="Code">💻</a> <a href="https://github.com/snipe/snipe-it/commits?author=bryanlopezinc" title="Tests">⚠️</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/U-H-T"><img src="https://avatars.githubusercontent.com/u/64061710?v=4?s=110" width="110px;" alt="U-H-T"/><br /><sub><b>U-H-T</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=U-H-T" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Tyree"><img src="https://avatars.githubusercontent.com/u/5395363?v=4?s=110" width="110px;" alt="Matt Tyree"/><br /><sub><b>Matt Tyree</b></sub></a><br /><a href="https://github.com/snipe/snipe-it/commits?author=Tyree" title="Documentation">📖</a></td>
</tr>
</tbody>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END --> <!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome! This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
+23 -17
View File
@@ -1,8 +1,8 @@
FROM ubuntu:24.04 FROM ubuntu:22.04
LABEL maintainer="Brady Wetherington <bwetherington@grokability.com>" LABEL maintainer="Brady Wetherington <bwetherington@grokability.com>"
# No need to add `apt-get clean` here, reference: # No need to add `apt-get clean` here, reference:
# - https://github.com/grokability/snipe-it/pull/9201 # - https://github.com/snipe/snipe-it/pull/9201
# - https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#apt-get # - https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#apt-get
RUN export DEBIAN_FRONTEND=noninteractive; \ RUN export DEBIAN_FRONTEND=noninteractive; \
@@ -14,16 +14,16 @@ RUN export DEBIAN_FRONTEND=noninteractive; \
apt-utils \ apt-utils \
apache2 \ apache2 \
apache2-bin \ apache2-bin \
libapache2-mod-php8.3 \ libapache2-mod-php8.1 \
php8.3-curl \ php8.1-curl \
php8.3-ldap \ php8.1-ldap \
php8.3-mysql \ php8.1-mysql \
php8.3-gd \ php8.1-gd \
php8.3-xml \ php8.1-xml \
php8.3-mbstring \ php8.1-mbstring \
php8.3-zip \ php8.1-zip \
php8.3-bcmath \ php8.1-bcmath \
php8.3-redis \ php8.1-redis \
php-memcached \ php-memcached \
patch \ patch \
curl \ curl \
@@ -40,7 +40,8 @@ autoconf \
libc-dev \ libc-dev \
libldap-common \ libldap-common \
pkg-config \ pkg-config \
php8.3-dev \ libmcrypt-dev \
php8.1-dev \
ca-certificates \ ca-certificates \
unzip \ unzip \
dnsutils \ dnsutils \
@@ -50,13 +51,18 @@ dnsutils \
RUN curl -L -O https://github.com/pear/pearweb_phars/raw/master/go-pear.phar RUN curl -L -O https://github.com/pear/pearweb_phars/raw/master/go-pear.phar
RUN php go-pear.phar RUN php go-pear.phar
RUN pecl install mcrypt
RUN bash -c "echo extension=/usr/lib/php/20210902/mcrypt.so > /etc/php/8.1/mods-available/mcrypt.ini"
RUN phpenmod mcrypt
RUN phpenmod gd RUN phpenmod gd
RUN phpenmod bcmath RUN phpenmod bcmath
RUN sed -i 's/variables_order = .*/variables_order = "EGPCS"/' /etc/php/8.3/apache2/php.ini RUN sed -i 's/variables_order = .*/variables_order = "EGPCS"/' /etc/php/8.1/apache2/php.ini
RUN sed -i 's/variables_order = .*/variables_order = "EGPCS"/' /etc/php/8.3/cli/php.ini RUN sed -i 's/variables_order = .*/variables_order = "EGPCS"/' /etc/php/8.1/cli/php.ini
RUN useradd -m --uid 10000 --gid 50 docker RUN useradd -m --uid 1000 --gid 50 docker
RUN echo export APACHE_RUN_USER=docker >> /etc/apache2/envvars RUN echo export APACHE_RUN_USER=docker >> /etc/apache2/envvars
RUN echo export APACHE_RUN_GROUP=staff >> /etc/apache2/envvars RUN echo export APACHE_RUN_GROUP=staff >> /etc/apache2/envvars
@@ -110,7 +116,7 @@ COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Get dependencies # Get dependencies
USER docker USER docker
RUN COMPOSER_CACHE_DIR=/dev/null composer install --no-dev --working-dir=/var/www/html && rm -rf /var/www/html/vendor/*/*/.git RUN composer install --no-dev --working-dir=/var/www/html
USER root USER root
############### APPLICATION INSTALL/INIT ################# ############### APPLICATION INSTALL/INIT #################
+35 -35
View File
@@ -1,35 +1,35 @@
FROM alpine:3.19 FROM alpine:3.18.6
# Apache + PHP # Apache + PHP
RUN apk add --no-cache \ RUN apk add --no-cache \
apache2 \ apache2 \
php82 \ php81 \
php82-common \ php81-common \
php82-apache2 \ php81-apache2 \
php82-curl \ php81-curl \
php82-ldap \ php81-ldap \
php82-mysqli \ php81-mysqli \
php82-gd \ php81-gd \
php82-xml \ php81-xml \
php82-mbstring \ php81-mbstring \
php82-zip \ php81-zip \
php82-ctype \ php81-ctype \
php82-tokenizer \ php81-tokenizer \
php82-pdo_mysql \ php81-pdo_mysql \
php82-openssl \ php81-openssl \
php82-bcmath \ php81-bcmath \
php82-phar \ php81-phar \
php82-json \ php81-json \
php82-iconv \ php81-iconv \
php82-fileinfo \ php81-fileinfo \
php82-simplexml \ php81-simplexml \
php82-session \ php81-session \
php82-dom \ php81-dom \
php82-xmlwriter \ php81-xmlwriter \
php82-xmlreader \ php81-xmlreader \
php82-sodium \ php81-sodium \
php82-redis \ php81-redis \
php82-pecl-memcached \ php81-pecl-memcached \
php82-exif \ php81-exif \
curl \ curl \
wget \ wget \
vim \ vim \
@@ -42,7 +42,7 @@ COPY docker/column-statistics.cnf /etc/mysql/conf.d/column-statistics.cnf
# Where apache's PID lives # Where apache's PID lives
RUN mkdir -p /run/apache2 && chown apache:apache /run/apache2 RUN mkdir -p /run/apache2 && chown apache:apache /run/apache2
RUN sed -i 's/variables_order = .*/variables_order = "EGPCS"/' /etc/php82/php.ini RUN sed -i 's/variables_order = .*/variables_order = "EGPCS"/' /etc/php81/php.ini
COPY docker/000-default-2.4.conf /etc/apache2/conf.d/default.conf COPY docker/000-default-2.4.conf /etc/apache2/conf.d/default.conf
# Enable mod_rewrite # Enable mod_rewrite
@@ -73,18 +73,18 @@ RUN mkdir -p /var/www/.composer && chown apache /var/www/.composer
# Install dependencies # Install dependencies
USER apache USER apache
RUN COMPOSER_CACHE_DIR=/dev/null composer install --working-dir=/var/www/html RUN COMPOSER_CACHE_DIR=/dev/null composer install --no-dev --working-dir=/var/www/html
USER root USER root
VOLUME ["/var/lib/snipeit"] VOLUME ["/var/lib/snipeit"]
# Startup script # Entrypoints
COPY docker/startup_alpine.sh /startup.sh COPY docker/entrypoint_alpine.sh /entrypoint.sh
RUN chmod +x /startup.sh RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/sbin/tini", "--"] ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/startup.sh"] CMD ["/entrypoint.sh"]
EXPOSE 80 EXPOSE 80
+4 -4
View File
@@ -70,7 +70,7 @@ COPY --from=composer /usr/bin/composer /usr/local/bin
ARG COMPOSER_ALLOW_SUPERUSER=1 ARG COMPOSER_ALLOW_SUPERUSER=1
RUN set -eux; \ RUN set -eux; \
# Download and extract snipeit tarball # Download and extract snipeit tarball
curl -o snipeit.tar.gz -fL "https://github.com/grokability/snipe-it/archive/v$SNIPEIT_RELEASE.tar.gz"; \ curl -o snipeit.tar.gz -fL "https://github.com/snipe/snipe-it/archive/v$SNIPEIT_RELEASE.tar.gz"; \
tar -xzf snipeit.tar.gz --strip-components=1 -C /var/www/html/; \ tar -xzf snipeit.tar.gz --strip-components=1 -C /var/www/html/; \
rm snipeit.tar.gz; \ rm snipeit.tar.gz; \
# Install composer php dependencies # Install composer php dependencies
@@ -97,7 +97,7 @@ RUN set -eux; \
VOLUME [ "/var/lib/snipeit" ] VOLUME [ "/var/lib/snipeit" ]
COPY --chown=www-data:www-data docker/docker-secrets.env /var/www/html/.env COPY --chown=www-data:www-data docker/docker-secrets.env /var/www/html/.env
COPY --chmod=655 docker/startup_alpine_fpm.sh /startup.sh COPY --chmod=655 docker/docker-entrypoint.sh /usr/local/bin/docker-snipeit-entrypoint
COPY docker/column-statistics.cnf /etc/mysql/conf.d/column-statistics.cnf COPY docker/column-statistics.cnf /etc/mysql/conf.d/column-statistics.cnf
ENTRYPOINT [ "/startup.sh" ] ENTRYPOINT [ "/usr/local/bin/docker-snipeit-entrypoint" ]
CMD [ "/startup.sh", "php-fpm" ] CMD [ "/usr/local/bin/docker-php-entrypoint", "php-fpm" ]
+27 -75
View File
@@ -1,34 +1,19 @@
![snipe-it-by-grok](https://github.com/grokability/snipe-it/assets/197404/b515673b-c7c8-4d9a-80f5-9fa58829a602) ![snipe-it-by-grok](https://github.com/snipe/snipe-it/assets/197404/b515673b-c7c8-4d9a-80f5-9fa58829a602)
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/snipe-it/localized.svg)](https://crowdin.com/project/snipe-it) [![Docker Pulls](https://img.shields.io/docker/pulls/snipe/snipe-it.svg)](https://hub.docker.com/r/snipe/snipe-it/) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/804dd1beb14a41f38810ab77d64fc4fc)](https://app.codacy.com/gh/grokability/snipe-it/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![Tests](https://github.com/grokability/snipe-it/actions/workflows/tests.yml/badge.svg)](https://github.com/grokability/snipe-it/actions/workflows/tests.yml) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/snipe-it/localized.svg)](https://crowdin.com/project/snipe-it) [![Docker Pulls](https://img.shields.io/docker/pulls/snipe/snipe-it.svg)](https://hub.docker.com/r/snipe/snipe-it/) [![Twitter Follow](https://img.shields.io/twitter/follow/snipeitapp.svg?style=social)](https://twitter.com/snipeitapp) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/553ce52037fc43ea99149785afcfe641)](https://app.codacy.com/gh/snipe/snipe-it/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![Tests](https://github.com/snipe/snipe-it/actions/workflows/tests.yml/badge.svg)](https://github.com/snipe/snipe-it/actions/workflows/tests.yml)
[![All Contributors](https://img.shields.io/badge/all_contributors-331-orange.svg?style=flat-square)](#contributing) [![Discord](https://badgen.net/badge/icon/discord?icon=discord&label)](https://discord.gg/yZFtShAcKk) [![All Contributors](https://img.shields.io/badge/all_contributors-331-orange.svg?style=flat-square)](#contributing) [![Discord](https://badgen.net/badge/icon/discord?icon=discord&label)](https://discord.gg/yZFtShAcKk)
## Snipe-IT - Open Source Asset Management System ## Snipe-IT - Open Source Asset Management System
This is a FOSS project for asset management in IT Operations. Knowing who has which laptop, when it was purchased in order to depreciate it correctly, handling software licenses, etc. This is a FOSS project for asset management in IT Operations. Knowing who has which laptop, when it was purchased in order to depreciate it correctly, handling software licenses, etc.
It is built on [Laravel 11](http://laravel.com). It is built on [Laravel 10](http://laravel.com).
Snipe-IT is actively developed and we [release quite frequently](https://github.com/grokability/snipe-it/releases). ([Check out the live demo here](https://snipeitapp.com/demo/).) Snipe-IT is actively developed and we [release quite frequently](https://github.com/snipe/snipe-it/releases). ([Check out the live demo here](https://snipeitapp.com/demo/).)
> [!TIP] > [!TIP]
> __This is web-based software__. This means there is no executable file (aka no .exe files), and it must be run on a web server and accessed through a web browser. It runs on any Mac OSX, any flavor of Linux, as well as Windows, and we have a [Docker image](https://snipe-it.readme.io/docs/docker) available if that's what you're into. > __This is web-based software__. This means there is no executable file (aka no .exe files), and it must be run on a web server and accessed through a web browser. It runs on any Mac OSX, any flavor of Linux, as well as Windows, and we have a [Docker image](https://snipe-it.readme.io/docs/docker) available if that's what you're into.
-----
### Table of Contents
* [Installation](#installation)
* [User's Manual](#users-manual)
* [Bug Reports & Feature Requests](#bug-reports--feature-requests)
* [Security](#security)
* [Upgrading](#upgrading)
* [Translations!](#translations-)
* [Libraries, Modules & Related Projects](#libraries-modules--related-projects)
* [Join the Community!](#join-the-community)
* [Contributing](#contributing)
* [Announcement List](#announcement-list)
----- -----
### Installation ### Installation
@@ -37,6 +22,8 @@ For instructions on installing and configuring Snipe-IT on your server, check ou
If you're having trouble with the installation, please check the [Common Issues](https://snipe-it.readme.io/docs/common-issues) and [Getting Help](https://snipe-it.readme.io/docs/getting-help) documentation, and search this repository's open *and* closed issues for help. If you're having trouble with the installation, please check the [Common Issues](https://snipe-it.readme.io/docs/common-issues) and [Getting Help](https://snipe-it.readme.io/docs/getting-help) documentation, and search this repository's open *and* closed issues for help.
<!-- [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) -->
----- -----
### User's Manual ### User's Manual
For help using Snipe-IT, check out the [user's manual](https://snipe-it.readme.io/docs/overview). For help using Snipe-IT, check out the [user's manual](https://snipe-it.readme.io/docs/overview).
@@ -44,25 +31,24 @@ For help using Snipe-IT, check out the [user's manual](https://snipe-it.readme.i
----- -----
### Bug Reports & Feature Requests ### Bug Reports & Feature Requests
Feel free to check out the [GitHub Issues for this project](https://github.com/grokability/snipe-it/issues) to open a bug report or see what open issues you can help with. Please search through existing issues (open *and* closed) to see if your question has already been answered before opening a new issue. Feel free to check out the [GitHub Issues for this project](https://github.com/snipe/snipe-it/issues) to open a bug report or see what open issues you can help with. Please search through existing issues (open *and* closed) to see if your question has already been answered before opening a new issue.
> [!IMPORTANT] > [!IMPORTANT]
> **PLEASE see the [Getting Help Guidelines](https://snipe-it.readme.io/docs/getting-help) and [Common Issues](https://snipe-it.readme.io/docs/common-issues) before opening a ticket, and be sure to complete all of the questions in the Github Issue template to help us to help you as quickly as possible.** > **PLEASE see the [Getting Help Guidelines](https://snipe-it.readme.io/docs/getting-help) and [Common Issues](https://snipe-it.readme.io/docs/common-issues) before opening a ticket, and be sure to complete all of the questions in the Github Issue template to help us to help you as quickly as possible.**
>
----- -----
### Security
> [!IMPORTANT]
> **To report a security vulnerability, please email security@snipeitapp.com instead of using the issue tracker.**
-----
### Upgrading ### Upgrading
Please see the [upgrading documentation](https://snipe-it.readme.io/docs/upgrading) for instructions on upgrading Snipe-IT. Please see the [upgrading documentation](https://snipe-it.readme.io/docs/upgrading) for instructions on upgrading Snipe-IT.
------ ------
### Announcement List
To be notified of important news (such as new releases, security advisories, etc), [sign up for our list](http://eepurl.com/XyZKz). We'll never sell or give away your info, and we'll only email you when it's important.
------
### Translations! ### Translations!
Please see the [translations documentation](https://snipe-it.readme.io/docs/translations) for information about available languages and how to add translations to Snipe-IT. Please see the [translations documentation](https://snipe-it.readme.io/docs/translations) for information about available languages and how to add translations to Snipe-IT.
@@ -76,72 +62,38 @@ Since the release of the JSON REST API, several third-party developers have been
> [!NOTE] > [!NOTE]
> As these were created by third-parties, Snipe-IT cannot provide support for these project, and you should contact the developers directly if you need assistance. Additionally, Snipe-IT makes no guarantees as to the reliability, accuracy or maintainability of these libraries. Use at your own risk. :) > As these were created by third-parties, Snipe-IT cannot provide support for these project, and you should contact the developers directly if you need assistance. Additionally, Snipe-IT makes no guarantees as to the reliability, accuracy or maintainability of these libraries. Use at your own risk. :)
#### Libraries & Modules - [Python Module](https://github.com/jbloomer/SnipeIT-PythonAPI) by [@jbloomer](https://github.com/jbloomer)
- [SnipeSharp - .NET module in C#](https://github.com/barrycarey/SnipeSharp) by [@barrycarey](https://github.com/barrycarey) - [SnipeSharp - .NET module in C#](https://github.com/barrycarey/SnipeSharp) by [@barrycarey](https://github.com/barrycarey)
- [InQRy -unmaintained-](https://github.com/Microsoft/InQRy) by [@Microsoft](https://github.com/Microsoft)
- [SnipeitPS](https://github.com/snazy2000/SnipeitPS) by [@snazy2000](https://github.com/snazy2000) - Powershell API Wrapper for Snipe-it - [SnipeitPS](https://github.com/snazy2000/SnipeitPS) by [@snazy2000](https://github.com/snazy2000) - Powershell API Wrapper for Snipe-it
- [jamf2snipe](https://github.com/grokability/jamf2snipe) - Python script to sync assets between a JAMFPro instance and a Snipe-IT instance - [jamf2snipe](https://github.com/grokability/jamf2snipe) - Python script to sync assets between a JAMFPro instance and a Snipe-IT instance
- [jamf-snipe-rename](https://macblog.org/jamf-snipe-rename/) - Python script to rename computers in Jamf from Snipe-IT - [jamf-snipe-rename](https://macblog.org/jamf-snipe-rename/) - Python script to rename computers in Jamf from Snipe-IT
- [Marksman](https://github.com/Scope-IT/marksman) - A Windows agent for Snipe-IT
- [Snipe-IT plugin for Jira Service Desk](https://marketplace.atlassian.com/apps/1220964/snipe-it-for-jira) - [Snipe-IT plugin for Jira Service Desk](https://marketplace.atlassian.com/apps/1220964/snipe-it-for-jira)
- [Python 3 CSV importer](https://github.com/gastamper/snipeit-csvimporter) - allows importing assets into Snipe-IT based on Item Name rather than Asset Tag. - [Python 3 CSV importer](https://github.com/gastamper/snipeit-csvimporter) - allows importing assets into Snipe-IT based on Item Name rather than Asset Tag.
- [Snipe-IT Kubernetes Helm Chart](https://github.com/t3n/helm-charts/tree/master/snipeit) - For more information, [click here](https://hub.helm.sh/charts/t3n/snipeit). - [Snipe-IT Kubernetes Helm Chart](https://github.com/t3n/helm-charts/tree/master/snipeit) - For more information, [click here](https://hub.helm.sh/charts/t3n/snipeit).
- [Snipe-IT Bulk Edit](https://github.com/bricelabelle/snipe-it-bulkedit) - Google Script files to use Google Sheets as a bulk checkout/checkin/edit tool for Snipe-IT. - [Snipe-IT Bulk Edit](https://github.com/bricelabelle/snipe-it-bulkedit) - Google Script files to use Google Sheets as a bulk checkout/checkin/edit tool for Snipe-it.
- [MosyleSnipeSync](https://github.com/RodneyLeeBrands/MosyleSnipeSync) by [@Karpadiem](https://github.com/Karpadiem) - Python script to synchronize information between Mosyle and Snipe-IT. - [MosyleSnipeSync](https://github.com/RodneyLeeBrands/MosyleSnipeSync) by [@Karpadiem](https://github.com/Karpadiem) - Python script to synchronize information between Mosyle and Snipe-IT
- [WWW::SnipeIT](https://github.com/SEDC/perl-www-snipeit) by [@SEDC](https://github.com/SEDC) - perl module for accessing the API - [WWW::SnipeIT](https://github.com/SEDC/perl-www-snipeit) by [@SEDC](https://github.com/SEDC) - perl module for accessing the API
- [UniFi to Snipe-IT](https://www.edtechirl.com/p/snipe-it-and-azure-asset-management) originally by [@karpadiem](https://github.com/karpadiem) - Python script that synchronizes UniFi devices with Snipe-IT. - [UniFi to Snipe-IT](https://github.com/RodneyLeeBrands/UnifiSnipeSync) by [@karpadiem](https://github.com/karpadiem) - Python script that synchronizes UniFi devices with Snipe-IT.
- [Kandji2Snipe](https://github.com/grokability/kandji2snipe) by [@briangoldstein](https://github.com/briangoldstein) - Python script that synchronizes Kandji with Snipe-IT. - [Kandji2Snipe](https://github.com/grokability/kandji2snipe) by [@briangoldstein](https://github.com/briangoldstein) - Python script that synchronizes Kandji with Snipe-IT.
- [SnipeAgent](https://github.com/ReticentRobot/SnipeAgent) by [@ReticentRobot](https://github.com/ReticentRobot) - Windows agent for Snipe-IT. - [SnipeAgent](https://github.com/ReticentRobot/SnipeAgent) by @ReticentRobot - Windows agent for Snipe-IT
- [Gate Pass Generator](https://github.com/cha7uraAE/snipe-it-gate-pass-system) by [@cha7uraAE](https://github.com/cha7uraAE) - A Streamlit application for generating gate passes based on hardware data from a Snipe-IT API.
- [InQRy (archived)](https://github.com/Microsoft/InQRy) by [@Microsoft](https://github.com/Microsoft)
- [Marksman (archived)](https://github.com/Scope-IT/marksman) - A Windows agent for Snipe-IT
- [Python Module (archived)](https://github.com/jbloomer/SnipeIT-PythonAPI) by [@jbloomer](https://github.com/jbloomer)
We also have a handful of [Google Apps scripts](https://github.com/grokability/google-apps-scripts-for-snipe-it) to help with various tasks.
#### Mobile Apps
We're currently working on our own mobile app, but in the meantime, check out these third-party apps that work with Snipe-IT:
- [SnipeMate](https://snipemate.app/) (iOS, Google Play, Huawei AppGallery) by Mars Technology
- [Snipe-Scan](https://apps.apple.com/do/app/snipe-scan/id6744179400?uo=2) (iOS) by Nicolas Maton
- [Snipe-IT Assets Management](https://play.google.com/store/apps/details?id=com.diegogarciadev.assetsmanager.snipeit&hl=en&pli=1) (Google Play) by DiegoGarciaDEV
- [AssetX](https://apps.apple.com/my/app/assetx-for-snipe-it/id6741996196?uo=2) (iOS) for Snipe-IT by Rishi Gupta
-----
### Join the Community!
- **[Join our Discord](https://discord.gg/yZFtShAcKk)!** Its full of great people. We even wrote about it [here](https://grokstar.dev/culture/2024/06/the-unlikely-rise-of-discord-as-a-support-channel/)!
- **Follow us on Bluesky** at [@snipeitapp.com](https://bsky.app/profile/snipeitapp.com)
- **Follow us on Mastodon** at [hachyderm.io/@grokability](https://hachyderm.io/@grokability)
- **Follow our blog** at [Grokstar.Dev](https://grokstar.dev)
- **Subscribe here** on Github for notifications about new releases. (We recommend selecting "Releases" only for most users - this repo can get noisy.)
----- -----
### Contributing ### Contributing
**Please refrain from submitting issues or pull requests generated by fully-automated tools. Maintainers reserve the right, at their sole discretion, to close such submissions and to block any account responsible for them.** Please see the documentation on [contributing and developing for Snipe-IT](https://snipe-it.readme.io/docs/contributing-overview).
Contributions should follow from a human-to-human discussion in the form of an issue for the best chances of being merged into the core project. (Sometimes we might already be working on that feature, sometimes we've decided against ) Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms.
Please see the complete documentation on [contributing and developing for Snipe-IT](https://snipe-it.readme.io/docs/contributing-overview).
This project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms.
The ERD is available [online here](https://drawsql.app/templates/snipe-it). The ERD is available [online here](https://drawsql.app/templates/snipe-it).
Be sure to check out all of the [amazing people](CONTRIBUTORS.md) that have contributed to Snipe-IT over the years! [Here is a list](CONTRIBUTORS.md) of the wonderful people that have contributed to the Snipe-IT.
----- -----
### Star History ### Security
[![Star History Chart](https://api.star-history.com/svg?repos=grokability/snipe-it&type=Date)](https://www.star-history.com/#grokability/snipe-it&Date) > [!IMPORTANT]
> **To report a security vulnerability, please email security@snipeitapp.com instead of using the issue tracker.**
------
### Announcement List
To be notified of important news (such as new releases, security advisories, etc), [sign up for our list](http://eepurl.com/XyZKz). We'll never sell or give away your info, and we'll only email you when it's important.
We also usually make smaller announcements on our social accounts, our Discord, and our blog, so be sure to subscribe to those if you're looking for more granular announcements.
+3 -6
View File
@@ -10,13 +10,10 @@ however there are times when library dependencies and/or PHP/MySQL dependencies
make it impossible to backport security fixes on older versions. make it impossible to backport security fixes on older versions.
| Version | Supported | | Version | Supported |
|---------| ------------------ | | ------- | ------------------ |
| 8.x | :white_check_mark: | | 5.1.x | :white_check_mark: |
| 7.x | :white_check_mark: |
| 6.x | :x: |
| 5.1.x | :x: |
| 5.0.x | :x: | | 5.0.x | :x: |
| 4.0.x | :x: | | 4.0.x | :white_check_mark: |
| < 4.0 | :x: | | < 4.0 | :x: |
## Reporting a Vulnerability ## Reporting a Vulnerability
+1 -1
View File
@@ -20,7 +20,7 @@ APP_DEBUG=true
APP_KEY=base64:glJpcM7BYwWiBggp3SQ/+NlRkqsBQMaGEOjemXqJzOU= APP_KEY=base64:glJpcM7BYwWiBggp3SQ/+NlRkqsBQMaGEOjemXqJzOU=
APP_URL=http://localhost:8000 APP_URL=http://localhost:8000
APP_TIMEZONE='UTC' APP_TIMEZONE='UTC'
APP_LOCALE=en-US APP_LOCALE=en
# -------------------------------------------- # --------------------------------------------
# REQUIRED: DATABASE SETTINGS # REQUIRED: DATABASE SETTINGS
Vendored
+1 -1
View File
@@ -1,7 +1,7 @@
# -*- mode: ruby -*- # -*- mode: ruby -*-
# vi: set ft=ruby : # vi: set ft=ruby :
SNIPEIT_SH_URL= "https://raw.githubusercontent.com/grokability/snipe-it/master/snipeit.sh" SNIPEIT_SH_URL= "https://raw.githubusercontent.com/snipe/snipe-it/master/snipeit.sh"
NETWORK_BRIDGE= "en0: Wi-Fi (AirPort)" NETWORK_BRIDGE= "en0: Wi-Fi (AirPort)"
Vagrant.configure("2") do |config| Vagrant.configure("2") do |config|
+1 -1
View File
@@ -6,7 +6,7 @@
"it asset" "it asset"
], ],
"website": "https://snipeitapp.com/", "website": "https://snipeitapp.com/",
"repository": "https://github.com/grokability/snipe-it", "repository": "https://github.com/snipe/snipe-it",
"logo": "https://pbs.twimg.com/profile_images/976748875733020672/K-HnZCCK_400x400.jpg", "logo": "https://pbs.twimg.com/profile_images/976748875733020672/K-HnZCCK_400x400.jpg",
"success_url": "/setup", "success_url": "/setup",
"env": { "env": {
@@ -1,48 +0,0 @@
<?php
namespace App\Actions\CheckoutRequests;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\RequestAssetCancelation;
use Illuminate\Auth\Access\AuthorizationException;
class CancelCheckoutRequestAction
{
public static function run(Asset $asset, User $user)
{
if (!Company::isCurrentUserHasAccess($asset)) {
throw new AuthorizationException();
}
$asset->cancelRequest();
$asset->decrement('requests_counter', 1);
$data['item'] = $asset;
$data['target'] = $user;
$data['item_quantity'] = 1;
$settings = Setting::getSettings();
$logaction = new Actionlog();
$logaction->item_id = $data['asset_id'] = $asset->id;
$logaction->item_type = $data['item_type'] = Asset::class;
$logaction->created_at = $data['requested_date'] = date('Y-m-d H:i:s');
$logaction->target_id = $data['user_id'] = auth()->id();
$logaction->target_type = User::class;
$logaction->location_id = $user->location_id ?? null;
$logaction->logaction('request canceled');
try {
$settings->notify(new RequestAssetCancelation($data));
} catch (\Exception $e) {
\Log::warning($e);
}
return true;
}
}
@@ -1,54 +0,0 @@
<?php
namespace App\Actions\CheckoutRequests;
use App\Exceptions\AssetNotRequestable;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\RequestAssetNotification;
use Illuminate\Auth\Access\AuthorizationException;
use Log;
class CreateCheckoutRequestAction
{
/**
* @throws AssetNotRequestable
* @throws AuthorizationException
*/
public static function run(Asset $asset, User $user): string
{
if (is_null(Asset::RequestableAssets()->find($asset->id))) {
throw new AssetNotRequestable($asset);
}
if (!Company::isCurrentUserHasAccess($asset)) {
throw new AuthorizationException();
}
$data['item'] = $asset;
$data['target'] = $user;
$data['item_quantity'] = 1;
$settings = Setting::getSettings();
$logaction = new Actionlog();
$logaction->item_id = $data['asset_id'] = $asset->id;
$logaction->item_type = $data['item_type'] = Asset::class;
$logaction->created_at = $data['requested_date'] = date('Y-m-d H:i:s');
$logaction->target_id = $data['user_id'] = auth()->id();
$logaction->target_type = User::class;
$logaction->location_id = $user->location_id ?? null;
$logaction->logaction('requested');
$asset->request();
$asset->increment('requests_counter', 1);
try {
$settings->notify(new RequestAssetNotification($data));
} catch (\Exception $e) {
Log::warning($e);
}
return true;
}
}
@@ -1,68 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\CheckoutAcceptance;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Console\Command;
class CleanIncorrectCheckoutAcceptances extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'snipeit:clean-checkout-acceptances';
/**
* The console command description.
*
* @var string
*/
protected $description = "Delete checkout acceptances for checkouts to non-users";
/**
* Execute the console command.
*/
public function handle()
{
$deletions = 0;
$skips = 0;
// This walks *every* checkoutacceptance. That's gnarly. But necessary
$this->withProgressBar(CheckoutAcceptance::all(), function ($checkoutAcceptance) use (&$deletions, &$skips) {
$item = $checkoutAcceptance->checkoutable;
$checkout_to_id = $checkoutAcceptance->assigned_to_id;
if(is_null($item)) {
$this->info("'Checkoutable' Item is null, going to next record");
return; //'false' allegedly breaks execution entirely, so 'true' maybe doesn't? hrm. just straight return maybe?
}
if(get_class($item) == LicenseSeat::class) {
$item = $item->license;
}
foreach($item->assetlog()->where('action_type','checkout')->get() as $assetlog) {
if ($assetlog->target_id == $checkout_to_id && $assetlog->target_type != User::class) {
//We have a checkout-to an ID for a non-User, which matches to an ID in the checkout_acceptances table
//now, let's compare the _times_ - are they close?
//I'm picking `created_at` over `action_date` because I'm more interested in when the actionlogs
//were _created_, not when they were alleged to have happened - those created_at times need to be within 'X' seconds of
//each other (currently 5)
if ($assetlog->created_at->diffInSeconds($checkoutAcceptance->created_at, true) <= 5) { //we're allowing for five _ish_ seconds of slop
$deletions++;
$checkoutAcceptance->forceDelete(); // HARD delete this record; it should have never been
return;
} else {
//$this->info("The two records are too far apart");
}
} else {
//$this->info("No match! checkout to id: " . $checkout_to_id." target_id: ".$assetlog->target_id." target_type: ".$assetlog->target_type);
}
}
$skips++;
});
$this->error("Final deletion count: $deletions, and skip count: $skips");
}
}
@@ -1,74 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\CheckoutRequest;
use Illuminate\Console\Command;
class CleanOldCheckoutRequests extends Command
{
private int $deletions = 0;
private int $skips = 0;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'snipeit:clean-old-checkout-requests';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Removes checkout requests that reference deleted assets or users.';
/**
* Execute the console command.
*/
public function handle()
{
$requests = CheckoutRequest::with([
'user' => function ($query) {
$query->withTrashed();
},
'requestedItem' => function ($query) {
$query->withTrashed();
},
])->get();
$this->info("Processing {$requests->count()} checkout requests");
$this->withProgressBar($requests, function ($request) {
if ($this->shouldForceDelete($request)) {
$request->forceDelete();
$this->deletions++;
return;
}
if ($this->shouldSoftDelete($request)) {
$request->delete();
$this->deletions++;
return;
}
$this->skips++;
});
$this->info("Final deletion count: $this->deletions, and skip count: $this->skips");
return 0;
}
private function shouldForceDelete(CheckoutRequest $request)
{
// check if the requestable or user relationship is null
return !$request->requestable || !$request->user;
}
private function shouldSoftDelete(CheckoutRequest $request)
{
return $request->requestable->trashed() || $request->user->trashed();
}
}
-53
View File
@@ -1,53 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Setting;
use Illuminate\Console\Command;
class DisableSAML extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'snipeit:saml-disable';
/**
* The console command description.
*
* @var string
*/
protected $description = 'This is a rescue command that can be used to turn off SAML settings in the event that you managed to lock yourself out using bad SAML settings.';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
if ($this->confirm("\n****************************************************\nThis will disable SAML support. You will not be able \nto login with an account that does not exist \nlocally in the Snipe-IT local database. \n****************************************************\n\nDo you wish to continue? [y|N]")) {
$setting = Setting::getSettings();
$setting->saml_enabled = 0;
if ($setting->save()) {
$this->info('SAML has been set to disabled.');
} else {
$this->info('Unable to disable SAML.');
}
} else {
$this->info('Canceled. No actions taken.');
}
}
}
@@ -1,151 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Accessory;
use App\Models\Actionlog;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
class FixBulkAccessoryCheckinActionLogEntries extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'snipeit:fix-bulk-accessory-action-log-entries {--dry-run : Run the sync process but don\'t update the database} {--skip-backup : Skip pre-execution backup}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'This script attempts to fix timestamps and missing created_by values for bulk checkin entries in the log table';
private bool $dryrun = false;
private bool $skipBackup = false;
/**
* Execute the console command.
*/
public function handle()
{
$this->skipBackup = $this->option('skip-backup');
$this->dryrun = $this->option('dry-run');
if ($this->dryrun) {
$this->info('This is a DRY RUN - no changes will be saved.');
$this->newLine();
}
$logs = Actionlog::query()
// only look for accessory checkin logs
->where('item_type', Accessory::class)
// that were part of a bulk checkin
->where('note', 'Bulk checkin items')
// logs that were improperly timestamped should have created_at in the 1970s
->whereYear('created_at', '1970')
->get();
if ($logs->isEmpty()) {
$this->info('No logs found with incorrect timestamps.');
return 0;
}
$this->info('Found ' . $logs->count() . ' logs with incorrect timestamps:');
$this->table(
['ID', 'Created By', 'Created At', 'Updated At'],
$logs->map(function ($log) {
return [
$log->id,
$log->created_by,
$log->created_at,
$log->updated_at,
];
})
);
if (!$this->dryrun && !$this->confirm('Update these logs?')) {
return 0;
}
if (!$this->dryrun && !$this->skipBackup) {
$this->info('Backing up the database before making changes...');
$this->call('snipeit:backup');
}
if ($this->dryrun) {
$this->newLine();
$this->info('DRY RUN. NOT ACTUALLY UPDATING LOGS.');
}
foreach ($logs as $log) {
$this->newLine();
$this->info('Processing log id:' . $log->id);
// created_by was not being set for accessory bulk checkins
// so let's see if there was another bulk checkin log
// with the same timestamp and a created_by value we can use.
if (is_null($log->created_by)) {
$createdByFromSimilarLog = $this->getCreatedByAttributeFromSimilarLog($log);
if ($createdByFromSimilarLog) {
$this->line(vsprintf('Updating log id:%s created_by to %s', [$log->id, $createdByFromSimilarLog]));
$log->created_by = $createdByFromSimilarLog;
} else {
$this->warn(vsprintf('No created_by found for log id:%s', [$log->id]));
$this->warn('Skipping updating this log since no similar log was found to update created_by from.');
// If we can't find a similar log then let's skip updating it
continue;
}
}
$this->line(vsprintf('Updating log id:%s from %s to %s', [$log->id, $log->created_at, $log->updated_at]));
$log->created_at = $log->updated_at;
if (!$this->dryrun) {
Model::withoutTimestamps(function () use ($log) {
$log->saveQuietly();
});
}
}
$this->newLine();
if ($this->dryrun) {
$this->info('DRY RUN. NO CHANGES WERE ACTUALLY MADE.');
}
return 0;
}
/**
* Hopefully the bulk checkin included other items like assets or licenses
* so we can use one of those logs to get the correct created_by value.
*
* This method attempts to find a bulk check in log that was
* created at the same time as the log passed in.
*/
private function getCreatedByAttributeFromSimilarLog(Actionlog $log): null|int
{
$similarLog = Actionlog::query()
->whereNotNull('created_by')
->where([
'action_type' => 'checkin from',
'note' => 'Bulk checkin items',
'target_id' => $log->target_id,
'target_type' => $log->target_type,
'created_at' => $log->updated_at,
])
->first();
if ($similarLog) {
return $similarLog->created_by;
}
return null;
}
}
@@ -1,32 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class FixUpAssignedTypeWithoutAssignedTo extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'snipeit:assigned-type-fixup';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fixes up assets that have an assigned_type but no assigned_to';
/**
* Execute the console command.
*/
public function handle()
{
DB::table('assets')->whereNotNull('assigned_type')->whereNull('assigned_to')->update(['assigned_type' => null]);
$this->info("Assets with an assigned_type but no assigned_to are fixed");
}
}
@@ -1,66 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Asset;
use Illuminate\Console\Command;
class FixupAssignedToWithoutAssignedType extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'snipeit:assigned-to-fixup
{--debug : Display debugging output}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fixes up assets that have an assigned_to but no assigned_type';
/**
* Execute the console command.
*/
public function handle()
{
$assets = Asset::whereNull("assigned_type")->whereNotNull("assigned_to")->withTrashed();
$this->withProgressBar($assets->get(), function (Asset $asset) {
//now check each action log, from the most recent backwards, to find the last checkin or checkout
foreach($asset->log()->orderBy("id","desc")->get() as $action_log) {
if($this->option("debug")) {
$this->info("Asset id: " . $asset->id . " action log, action type is: " . $action_log->action_type);
}
switch($action_log->action_type) {
case 'checkin from':
if($this->option("debug")) {
$this->info("Doing a checkin for ".$asset->id);
}
$asset->assigned_to = null;
// if you have a required custom field, we still want to save, and we *don't* want an action_log
$asset->saveQuietly();
return;
case 'checkout':
if($this->option("debug")) {
$this->info("Doing a checkout for " . $asset->id . " picking target type: " . $action_log->target_type);
}
if($asset->assigned_to != $action_log->target_id) {
$this->error("Asset's assigned_to does *NOT* match Action Log's target_id. \$asset->assigned_to=".$asset->assigned_to." vs. \$action_log->target_id=".$action_log->target_id);
//FIXME - do we abort here? Do we try to keep looking? I don't know, this means your data is *really* messed up...
}
$asset->assigned_type = $action_log->target_type;
$asset->saveQuietly(); // see above
return;
}
}
$asset->assigned_to = null; //asset was never checked in or out in its lifetime - it stays 'checked in'
$asset->saveQuietly(); //see above
});
$this->newLine();
$this->info("Assets assigned_type are fixed");
}
}
@@ -2,9 +2,11 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Helpers\Helper;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use App\Models\User; use App\Models\User;
use Laravel\Passport\TokenRepository; use Laravel\Passport\TokenRepository;
use Illuminate\Contracts\Validation\Factory as ValidationFactory;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class GeneratePersonalAccessToken extends Command class GeneratePersonalAccessToken extends Command
@@ -41,8 +43,9 @@ class GeneratePersonalAccessToken extends Command
* *
* @return void * @return void
*/ */
public function __construct(TokenRepository $tokenRepository) public function __construct(TokenRepository $tokenRepository, ValidationFactory $validation)
{ {
$this->validation = $validation;
$this->tokenRepository = $tokenRepository; $this->tokenRepository = $tokenRepository;
parent::__construct(); parent::__construct();
} }
@@ -73,7 +76,7 @@ class GeneratePersonalAccessToken extends Command
} else { } else {
$this->warn('Your API Token has been created. Be sure to copy this token now, as it WILL NOT be accessible again.'); $this->warn('Your API Token has been created. Be sure to copy this token now, as it will not be accessible again.');
if ($token = DB::table('oauth_access_tokens')->where('user_id', '=', $user->id)->where('name','=',$accessTokenName)->orderBy('created_at', 'desc')->first()) { if ($token = DB::table('oauth_access_tokens')->where('user_id', '=', $user->id)->where('name','=',$accessTokenName)->orderBy('created_at', 'desc')->first()) {
$this->info('API Token ID: '.$token->id); $this->info('API Token ID: '.$token->id);
+140 -208
View File
@@ -2,7 +2,6 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Asset;
use App\Models\Department; use App\Models\Department;
use App\Models\Group; use App\Models\Group;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@@ -54,30 +53,18 @@ class LdapSync extends Command
ini_set('max_execution_time', env('LDAP_TIME_LIM', 600)); //600 seconds = 10 minutes ini_set('max_execution_time', env('LDAP_TIME_LIM', 600)); //600 seconds = 10 minutes
ini_set('memory_limit', env('LDAP_MEM_LIM', '500M')); ini_set('memory_limit', env('LDAP_MEM_LIM', '500M'));
$ldap_result_username = Setting::getSettings()->ldap_username_field;
$ldap_result_last_name = Setting::getSettings()->ldap_lname_field;
// Map the LDAP attributes to the Snipe-IT user fields. $ldap_result_first_name = Setting::getSettings()->ldap_fname_field;
$ldap_map = [ $ldap_result_active_flag = Setting::getSettings()->ldap_active_flag;
"username" => Setting::getSettings()->ldap_username_field, $ldap_result_emp_num = Setting::getSettings()->ldap_emp_num;
"last_name" => Setting::getSettings()->ldap_lname_field, $ldap_result_email = Setting::getSettings()->ldap_email;
"first_name" => Setting::getSettings()->ldap_fname_field, $ldap_result_phone = Setting::getSettings()->ldap_phone_field;
"active_flag" => Setting::getSettings()->ldap_active_flag, $ldap_result_jobtitle = Setting::getSettings()->ldap_jobtitle;
"emp_num" => Setting::getSettings()->ldap_emp_num, $ldap_result_country = Setting::getSettings()->ldap_country;
"email" => Setting::getSettings()->ldap_email, $ldap_result_location = Setting::getSettings()->ldap_location;
"phone" => Setting::getSettings()->ldap_phone_field, $ldap_result_dept = Setting::getSettings()->ldap_dept;
"mobile" => Setting::getSettings()->ldap_mobile, $ldap_result_manager = Setting::getSettings()->ldap_manager;
"jobtitle" => Setting::getSettings()->ldap_jobtitle,
"address" => Setting::getSettings()->ldap_address,
"city" => Setting::getSettings()->ldap_city,
"state" => Setting::getSettings()->ldap_state,
"zip" => Setting::getSettings()->ldap_zip,
"country" => Setting::getSettings()->ldap_country,
"location" => Setting::getSettings()->ldap_location,
"dept" => Setting::getSettings()->ldap_dept,
"manager" => Setting::getSettings()->ldap_manager,
"display_name" => Setting::getSettings()->ldap_display_name,
];
$ldap_default_group = Setting::getSettings()->ldap_default_group; $ldap_default_group = Setting::getSettings()->ldap_default_group;
$search_base = Setting::getSettings()->ldap_base_dn; $search_base = Setting::getSettings()->ldap_base_dn;
@@ -120,25 +107,14 @@ class LdapSync extends Command
} }
/** /**
* If a filter has been specified, use that, otherwise default to null * If a filter has been specified, use that
*/ */
if ($this->option('filter') != '') { if ($this->option('filter') != '') {
$filter = $this->option('filter'); $results = Ldap::findLdapUsers($search_base, -1, $this->option('filter'));
} else { } else {
$filter = null; $results = Ldap::findLdapUsers($search_base);
} }
/**
* We only need to request the LDAP attributes that we process
*/
$attributes = array_values(array_filter($ldap_map));
if (Setting::getSettings()->is_ad === 1 && is_null($ldap_map['active_flag'])) {
$attributes[] = 'useraccountcontrol';
}
$results = Ldap::findLdapUsers($search_base, -1, $filter, $attributes);
} catch (\Exception $e) { } catch (\Exception $e) {
if ($this->option('json_summary')) { if ($this->option('json_summary')) {
$json_summary = ['error' => true, 'error_message' => $e->getMessage(), 'summary' => []]; $json_summary = ['error' => true, 'error_message' => $e->getMessage(), 'summary' => []];
@@ -150,24 +126,23 @@ class LdapSync extends Command
} }
/* Determine which location to assign users to by default. */ /* Determine which location to assign users to by default. */
$default_location = null; $location = null; // TODO - this would be better called "$default_location", which is more explicit about its purpose
if ($this->option('location') != '') { if ($this->option('location') != '') {
if ($default_location = Location::where('name', '=', $this->option('location'))->first()) { if ($location = Location::where('name', '=', $this->option('location'))->first()) {
Log::debug('Location name ' . $this->option('location') . ' passed'); Log::debug('Location name ' . $this->option('location') . ' passed');
Log::debug('Importing to '.$default_location->name.' ('.$default_location->id.')'); Log::debug('Importing to ' . $location->name . ' (' . $location->id . ')');
} }
} elseif ($this->option('location_id')) { } elseif ($this->option('location_id')) {
//TODO - figure out how or why this is an array?
foreach($this->option('location_id') as $location_id) { foreach($this->option('location_id') as $location_id) {
if ($default_location = Location::where('id', '=', $location_id)->first()) { if ($location = Location::where('id', '=', $location_id)->first()) {
Log::debug('Location ID ' . $location_id . ' passed'); Log::debug('Location ID ' . $location_id . ' passed');
Log::debug('Importing to '.$default_location->name.' ('.$default_location->id.')'); Log::debug('Importing to ' . $location->name . ' (' . $location->id . ')');
} }
} }
} }
if (!isset($default_location)) { if (! isset($location)) {
Log::debug('That location is invalid or a location was not provided, so no location will be assigned by default.'); Log::debug('That location is invalid or a location was not provided, so no location will be assigned by default.');
} }
@@ -190,7 +165,7 @@ class LdapSync extends Command
// Inject location information fields // Inject location information fields
for ($i = 0; $i < $results['count']; $i++) { for ($i = 0; $i < $results['count']; $i++) {
$results[$i]['ldap_location_override'] = false; $results[$i]['ldap_location_override'] = false;
$results[$i]['location_id'] = null; $results[$i]['location_id'] = 0;
} }
// Grab subsets based on location-specific DNs, and overwrite location for these users. // Grab subsets based on location-specific DNs, and overwrite location for these users.
@@ -208,17 +183,17 @@ class LdapSync extends Command
} }
$usernames = []; $usernames = [];
for ($i = 0; $i < $location_users['count']; $i++) { for ($i = 0; $i < $location_users['count']; $i++) {
if (array_key_exists($ldap_map["username"], $location_users[$i])) { if (array_key_exists($ldap_result_username, $location_users[$i])) {
$location_users[$i]['ldap_location_override'] = true; $location_users[$i]['ldap_location_override'] = true;
$location_users[$i]['location_id'] = $ldap_loc['id']; $location_users[$i]['location_id'] = $ldap_loc['id'];
$usernames[] = $location_users[$i][$ldap_map["username"]][0]; $usernames[] = $location_users[$i][$ldap_result_username][0];
} }
} }
// Delete located users from the general group. // Delete located users from the general group.
foreach ($results as $key => $generic_entry) { foreach ($results as $key => $generic_entry) {
if ((is_array($generic_entry)) && (array_key_exists($ldap_map["username"], $generic_entry))) { if ((is_array($generic_entry)) && (array_key_exists($ldap_result_username, $generic_entry))) {
if (in_array($generic_entry[$ldap_map["username"]][0], $usernames)) { if (in_array($generic_entry[$ldap_result_username][0], $usernames)) {
unset($results[$key]); unset($results[$key]);
} }
} }
@@ -242,92 +217,77 @@ class LdapSync extends Command
} }
// Assign the mapped LDAP attributes for each user to the Snipe-IT user fields
for ($i = 0; $i < $results['count']; $i++) { for ($i = 0; $i < $results['count']; $i++) {
$item = []; $item = [];
$item['username'] = $results[$i][$ldap_map["username"]][0] ?? ''; $item['username'] = $results[$i][$ldap_result_username][0] ?? '';
$item['display_name'] = $results[$i][$ldap_map["display_name"]][0] ?? ''; $item['employee_number'] = $results[$i][$ldap_result_emp_num][0] ?? '';
$item['employee_number'] = $results[$i][$ldap_map["emp_num"]][0] ?? ''; $item['lastname'] = $results[$i][$ldap_result_last_name][0] ?? '';
$item['lastname'] = $results[$i][$ldap_map["last_name"]][0] ?? ''; $item['firstname'] = $results[$i][$ldap_result_first_name][0] ?? '';
$item['firstname'] = $results[$i][$ldap_map["first_name"]][0] ?? ''; $item['email'] = $results[$i][$ldap_result_email][0] ?? '';
$item['email'] = $results[$i][$ldap_map["email"]][0] ?? ''; $item['ldap_location_override'] = $results[$i]['ldap_location_override'] ?? '';
$item['ldap_location_override'] = $results[$i]['ldap_location_override'] ?? ''; $item['location_id'] = $results[$i]['location_id'] ?? '';
$item['location_id'] = $results[$i]['location_id'] ?? ''; $item['telephone'] = $results[$i][$ldap_result_phone][0] ?? '';
$item['telephone'] = $results[$i][$ldap_map["phone"]][0] ?? ''; $item['jobtitle'] = $results[$i][$ldap_result_jobtitle][0] ?? '';
$item['mobile'] = $results[$i][$ldap_map["mobile"]][0] ?? ''; $item['country'] = $results[$i][$ldap_result_country][0] ?? '';
$item['jobtitle'] = $results[$i][$ldap_map["jobtitle"]][0] ?? ''; $item['department'] = $results[$i][$ldap_result_dept][0] ?? '';
$item['address'] = $results[$i][$ldap_map["address"]][0] ?? ''; $item['manager'] = $results[$i][$ldap_result_manager][0] ?? '';
$item['city'] = $results[$i][$ldap_map["city"]][0] ?? ''; $item['location'] = $results[$i][$ldap_result_location][0] ?? '';
$item['state'] = $results[$i][$ldap_map["state"]][0] ?? '';
$item['country'] = $results[$i][$ldap_map["country"]][0] ?? '';
$item['zip'] = $results[$i][$ldap_map["zip"]][0] ?? '';
$item['department'] = $results[$i][$ldap_map["dept"]][0] ?? '';
$item['manager'] = $results[$i][$ldap_map["manager"]][0] ?? '';
$item['location'] = $results[$i][$ldap_map["location"]][0] ?? '';
$location = $default_location; //initially, set '$location' to the default_location (which may just be `null`)
// ONLY if you are using the "ldap_location" option *AND* you have an actual result // ONLY if you are using the "ldap_location" option *AND* you have an actual result
if ($ldap_map["location"] && $item['location']) { if ($ldap_result_location && $item['location']) {
$location = Location::firstOrCreate([ $location = Location::firstOrCreate([
'name' => $item['location'], 'name' => $item['location'],
]);
}
$department = Department::firstOrCreate([
'name' => $item['department'],
]); ]);
}
$department = Department::firstOrCreate([
'name' => $item['department'],
]);
$user = User::where('username', $item['username'])->first(); $user = User::where('username', $item['username'])->first();
if ($user) { if ($user) {
// Updating an existing user. // Updating an existing user.
$item['createorupdate'] = 'updated'; $item['createorupdate'] = 'updated';
} else { } else {
// Creating a new user. // Creating a new user.
$user = new User; $user = new User;
$user->password = $user->noPassword(); $user->password = $user->noPassword();
$user->locale = app()->getLocale(); $user->activated = 1; // newly created users can log in by default, unless AD's UAC is in use, or an active flag is set (below)
$user->activated = 1; // newly created users can log in by default, unless AD's UAC is in use, or an active flag is set (below) $item['createorupdate'] = 'created';
$item['createorupdate'] = 'created'; }
}
//If a sync option is not filled in on the LDAP settings don't populate the user field //If a sync option is not filled in on the LDAP settings don't populate the user field
if($ldap_map["username"] != null){ if($ldap_result_username != null){
$user->username = $item['username']; $user->username = $item['username'];
} }
if($ldap_map["display_name"] != null){ if($ldap_result_last_name != null){
$user->display_name = $item['display_name'];
}
if($ldap_map["last_name"] != null){
$user->last_name = $item['lastname']; $user->last_name = $item['lastname'];
} }
if($ldap_map["first_name"] != null){ if($ldap_result_first_name != null){
$user->first_name = $item['firstname']; $user->first_name = $item['firstname'];
} }
if($ldap_map["emp_num"] != null){ if($ldap_result_emp_num != null){
$user->employee_num = e($item['employee_number']); $user->employee_num = e($item['employee_number']);
} }
if($ldap_map["email"] != null){ if($ldap_result_email != null){
$user->email = $item['email']; $user->email = $item['email'];
} }
if($ldap_map["phone"] != null){ if($ldap_result_phone != null){
$user->phone = $item['telephone']; $user->phone = $item['telephone'];
} }
if($ldap_map["mobile"] != null){ if($ldap_result_jobtitle != null){
$user->mobile = $item['mobile'];
}
if($ldap_map["jobtitle"] != null){
$user->jobtitle = $item['jobtitle']; $user->jobtitle = $item['jobtitle'];
} }
if($ldap_map["country"] != null){ if($ldap_result_country != null){
$user->country = $item['country']; $user->country = $item['country'];
} }
if($ldap_map["dept"] != null){ if($ldap_result_dept != null){
$user->department_id = $department->id; $user->department_id = $department->id;
} }
if($ldap_map["location"] != null){ if($ldap_result_location != null){
$user->location_id = $location?->id; $user->location_id = $location ? $location->id : null;
} }
if($ldap_map["manager"] != null){ if($ldap_result_manager != null){
if($item['manager'] != null) { if($item['manager'] != null) {
// Check Cache first // Check Cache first
if (isset($manager_cache[$item['manager']])) { if (isset($manager_cache[$item['manager']])) {
@@ -344,76 +304,63 @@ class LdapSync extends Command
$ldap_manager = [ $ldap_manager = [
"count" => 1, "count" => 1,
0 => [ 0 => [
$ldap_map["username"] => [$item['manager']] $ldap_result_username => [$item['manager']]
] ]
]; ];
} }
$add_manager_to_cache = true;
if ($ldap_manager["count"] > 0) { if ($ldap_manager["count"] > 0) {
try {
// Get the Manager's username
// PHP LDAP returns every LDAP attribute as an array, and 90% of the time it's an array of just one item. But, hey, it's an array.
$ldapManagerUsername = $ldap_manager[0][$ldap_map["username"]][0];
// Get User from Manager username. // Get the Manager's username
$ldap_manager = User::where('username', $ldapManagerUsername)->first(); // PHP LDAP returns every LDAP attribute as an array, and 90% of the time it's an array of just one item. But, hey, it's an array.
$ldapManagerUsername = $ldap_manager[0][$ldap_result_username][0];
if ($ldap_manager && isset($ldap_manager->id)) { // Get User from Manager username.
// Link user to manager id. $ldap_manager = User::where('username', $ldapManagerUsername)->first();
$user->manager_id = $ldap_manager->id;
} if ($ldap_manager && isset($ldap_manager->id)) {
} catch (\Exception $e) { // Link user to manager id.
$add_manager_to_cache = false; $user->manager_id = $ldap_manager->id;
\Log::warning('Handling ldap manager ' . $item['manager'] . ' caused an exception: ' . $e->getMessage() . '. Continuing synchronization.');
} }
} }
if ($add_manager_to_cache) { $manager_cache[$item['manager']] = $ldap_manager && isset($ldap_manager->id) ? $ldap_manager->id : null; // Store results in cache, even if 'failed'
$manager_cache[$item['manager']] = $ldap_manager && isset($ldap_manager->id) ? $ldap_manager->id : null; // Store results in cache, even if 'failed'
}
} }
} }
} }
// Sync activated state for Active Directory. // Sync activated state for Active Directory.
if (!empty($ldap_map["active_flag"])) { // IF we have an 'active' flag set.... if ( !empty($ldap_result_active_flag)) { // IF we have an 'active' flag set....
// ....then *most* things that are truthy will activate the user. Anything falsey will deactivate them. // ....then *most* things that are truthy will activate the user. Anything falsey will deactivate them.
// (Specifically, we don't handle a value of '0.0' correctly) // (Specifically, we don't handle a value of '0.0' correctly)
$raw_value = @$results[$i][$ldap_map["active_flag"]][0]; $raw_value = @$results[$i][$ldap_result_active_flag][0];
$filter_var = filter_var($raw_value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $filter_var = filter_var($raw_value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$boolean_cast = (bool)$raw_value;
$boolean_cast = (bool) $raw_value;
if (Setting::getSettings()->ldap_invert_active_flag === 1) {
// Because ldap_active_flag is set, if filter_var is true or boolean_cast is true, then user is suspended
$user->activated = !($filter_var ?? $boolean_cast);
}else{
$user->activated = $filter_var ?? $boolean_cast; // if filter_var() was true or false, use that. If it's null, use the $boolean_cast $user->activated = $filter_var ?? $boolean_cast; // if filter_var() was true or false, use that. If it's null, use the $boolean_cast
}
} elseif (array_key_exists('useraccountcontrol', $results[$i])) { } elseif (array_key_exists('useraccountcontrol', $results[$i]) ) {
// ....otherwise, (ie if no 'active' LDAP flag is defined), IF the UAC setting exists, // ....otherwise, (ie if no 'active' LDAP flag is defined), IF the UAC setting exists,
// ....then use the UAC setting on the account to determine can-log-in vs. cannot-log-in // ....then use the UAC setting on the account to determine can-log-in vs. cannot-log-in
/* The following is _probably_ the correct logic, but we can't use it because /* The following is _probably_ the correct logic, but we can't use it because
some users may have been dependent upon the previous behavior, and this some users may have been dependent upon the previous behavior, and this
could cause additional access to be available to users they don't want could cause additional access to be available to users they don't want
to allow to log in. to allow to log in.
$useraccountcontrol = $results[$i]['useraccountcontrol'][0]; $useraccountcontrol = $results[$i]['useraccountcontrol'][0];
if( if(
// based on MS docs at: https://support.microsoft.com/en-us/help/305144/how-to-use-useraccountcontrol-to-manipulate-user-account-properties // based on MS docs at: https://support.microsoft.com/en-us/help/305144/how-to-use-useraccountcontrol-to-manipulate-user-account-properties
($useraccountcontrol & 0x200) && // is a NORMAL_ACCOUNT ($useraccountcontrol & 0x200) && // is a NORMAL_ACCOUNT
!($useraccountcontrol & 0x02) && // *and* _not_ ACCOUNTDISABLE !($useraccountcontrol & 0x02) && // *and* _not_ ACCOUNTDISABLE
!($useraccountcontrol & 0x10) // *and* _not_ LOCKOUT !($useraccountcontrol & 0x10) // *and* _not_ LOCKOUT
) { ) {
$user->activated = 1; $user->activated = 1;
} else { } else {
$user->activated = 0; $user->activated = 0;
} */ } */
$enabled_accounts = [ $enabled_accounts = [
'512', // 0x200 NORMAL_ACCOUNT '512', // 0x200 NORMAL_ACCOUNT
'544', // 0x220 NORMAL_ACCOUNT, PASSWD_NOTREQD '544', // 0x220 NORMAL_ACCOUNT, PASSWD_NOTREQD
'66048', // 0x10200 NORMAL_ACCOUNT, DONT_EXPIRE_PASSWORD '66048', // 0x10200 NORMAL_ACCOUNT, DONT_EXPIRE_PASSWORD
@@ -426,59 +373,44 @@ class LdapSync extends Command
'4260352', // 0x410200 NORMAL_ACCOUNT, DONT_EXPIRE_PASSWORD, DONT_REQ_PREAUTH '4260352', // 0x410200 NORMAL_ACCOUNT, DONT_EXPIRE_PASSWORD, DONT_REQ_PREAUTH
'1049088', // 0x100200 NORMAL_ACCOUNT, NOT_DELEGATED '1049088', // 0x100200 NORMAL_ACCOUNT, NOT_DELEGATED
'1114624', // 0x110200 NORMAL_ACCOUNT, DONT_EXPIRE_PASSWORD, NOT_DELEGATED, '1114624', // 0x110200 NORMAL_ACCOUNT, DONT_EXPIRE_PASSWORD, NOT_DELEGATED,
]; ];
$user->activated = (in_array($results[$i]['useraccountcontrol'][0], $enabled_accounts)) ? 1 : 0; $user->activated = (in_array($results[$i]['useraccountcontrol'][0], $enabled_accounts)) ? 1 : 0;
// If we're not using AD, and there isn't an activated flag set, activate all users // If we're not using AD, and there isn't an activated flag set, activate all users
} /* implied 'else' here - leave the $user->activated flag alone. Newly-created accounts will be active. } /* implied 'else' here - leave the $user->activated flag alone. Newly-created accounts will be active.
already-existing accounts will be however the administrator has set them */ already-existing accounts will be however the administrator has set them */
if ($item['ldap_location_override'] == true) { if ($item['ldap_location_override'] == true) {
$user->location_id = $item['location_id']; $user->location_id = $item['location_id'];
} elseif ((isset($location)) && (!empty($location))) { } elseif ((isset($location)) && (! empty($location))) {
if ((is_array($location)) && (array_key_exists('id', $location))) { if ((is_array($location)) && (array_key_exists('id', $location))) {
$user->location_id = $location['id']; $user->location_id = $location['id'];
} elseif (is_object($location)) { } elseif (is_object($location)) {
$user->location_id = $location->id; //THIS is the magic line, this should do it. $user->location_id = $location->id;
}
}
// TODO - should we be NULLING locations if $location is really `null`, and that's what we came up with?
// will that conflict with any overriding setting that the user set? Like, if they moved someone from
// the 'null' location to somewhere, we wouldn't want to try to override that, right?
$location = null;
$user->ldap_import = 1;
$errors = '';
if ($user->save()) {
$item['note'] = $item['createorupdate'];
$item['status'] = 'success';
if ($item['createorupdate'] === 'created' && $ldap_default_group) {
// Check if the relationship already exists
if (!$user->groups()->where('group_id', $ldap_default_group)->exists()) {
$user->groups()->attach($ldap_default_group);
} }
} }
$location = null;
//updates assets location based on user's location $user->ldap_import = 1;
if ($user->wasChanged('location_id')) {
foreach ($user->assets as $asset) { $errors = '';
$asset->location_id = $user->location_id;
// TODO: somehow add note? "Asset Location Changed because of thing" if ($user->save()) {
$asset->save(); $item['note'] = $item['createorupdate'];
$item['status'] = 'success';
if ( $item['createorupdate'] === 'created' && $ldap_default_group) {
$user->groups()->attach($ldap_default_group);
} }
} else {
foreach ($user->getErrors()->getMessages() as $key => $err) {
$errors .= $err[0];
}
$item['note'] = $errors;
$item['status'] = 'error';
} }
} else { array_push($summary, $item);
foreach ($user->getErrors()->getMessages() as $key => $err) {
$errors .= $err[0];
}
$item['note'] = $errors;
$item['status'] = 'error';
}
array_push($summary, $item);
} }
if ($this->option('summary')) { if ($this->option('summary')) {
+55 -165
View File
@@ -6,7 +6,6 @@ use Illuminate\Console\Command;
use App\Models\Setting; use App\Models\Setting;
use Exception; use Exception;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use App\Models\Ldap;
/** /**
* Check if a given ip is in a network * Check if a given ip is in a network
@@ -161,15 +160,7 @@ class LdapTroubleshooter extends Command
$output[] = "-x"; $output[] = "-x";
$output[] = "-b ".escapeshellarg($settings->ldap_basedn); $output[] = "-b ".escapeshellarg($settings->ldap_basedn);
$output[] = "-D ".escapeshellarg($settings->ldap_uname); $output[] = "-D ".escapeshellarg($settings->ldap_uname);
$output[] = "-w ".escapeshellarg(Crypt::Decrypt($settings->ldap_pword));
try {
$w = Crypt::Decrypt($settings->ldap_pword);
} catch (\Exception $e) {
$this->warn("Could not decrypt password. This usually means an LDAP password was not set or the APP_KEY was changed since the LDAP pasword was last saved. Aborting.");
exit(0);
}
$output[] = "-w ". escapeshellarg($w);
$output[] = escapeshellarg(parenthesized_filter($settings->ldap_filter)); $output[] = escapeshellarg(parenthesized_filter($settings->ldap_filter));
if($settings->ldap_tls) { if($settings->ldap_tls) {
$this->line("# adding STARTTLS option"); $this->line("# adding STARTTLS option");
@@ -180,23 +171,6 @@ class LdapTroubleshooter extends Command
$this->line(implode(" \\\n",$output)); $this->line(implode(" \\\n",$output));
exit(0); exit(0);
} }
//PHP Version check for warning
$php_version = phpversion();
list($major, $minor, $patch) = explode('.', $php_version);
if (
$major < 8 ||
($major == 8 && $minor < 3) ||
($major == 8 && $minor == 3 && $patch < 21) ||
($major == 8 && $minor == 4 && $patch < 7)
) {
$this->warn("PHP Version: $php_version WARNING - Versions before 8.3.21 or 8.4.7 will return INCONSISTENT results!");
if (!$this->confirm("Are you sure you wish to continue?")) {
$this->warn("ABORTING");
exit(-1);
}
}
if(!$this->option('force')) { if(!$this->option('force')) {
$confirmation = $this->confirm('WARNING: This command will make several attempts to connect to your LDAP server. Are you sure this is ok?'); $confirmation = $this->confirm('WARNING: This command will make several attempts to connect to your LDAP server. Are you sure this is ok?');
if(!$confirmation) { if(!$confirmation) {
@@ -205,7 +179,7 @@ class LdapTroubleshooter extends Command
} }
} }
//$this->line(print_r($settings,true)); //$this->line(print_r($settings,true));
$this->line("STAGE 1: Checking settings"); $this->info("STAGE 1: Checking settings");
if(!$settings->ldap_enabled) { if(!$settings->ldap_enabled) {
$this->error("WARNING: Snipe-IT's LDAP setting is not turned on. (That may be OK if you're still trying to figure out settings)"); $this->error("WARNING: Snipe-IT's LDAP setting is not turned on. (That may be OK if you're still trying to figure out settings)");
} }
@@ -236,40 +210,32 @@ class LdapTroubleshooter extends Command
$this->info("Determined LDAP hostname to be: ".$parsed['host']); $this->info("Determined LDAP hostname to be: ".$parsed['host']);
} }
$this->info("Performing DNS lookup of: ".$parsed['host']);
$ips = dns_get_record($parsed['host']);
$raw_ips = []; $raw_ips = [];
if (inet_pton($parsed['host']) !== false) { //$this->info("Host IP is: ".print_r($ips,true));
$this->line($parsed['host'] . " already looks like an address; skipping DNS lookup");
$raw_ips[] = $parsed['host'];
} else {
$this->line("Performing DNS lookup of: " . $parsed['host']);
$ips = dns_get_record($parsed['host']);
//$this->info("Host IP is: ".print_r($ips,true)); if(!$ips || count($ips) == 0) {
$this->error("ERROR: DNS lookup of host: ".$parsed['host']." has failed. ABORTING.");
if (!$ips || count($ips) == 0) { exit(-1);
$this->error("ERROR: DNS lookup of host: " . $parsed['host'] . " has failed. ABORTING.");
exit(-1);
}
$this->debugout("IP's? " . print_r($ips, true));
foreach ($ips as $ip) {
if (!isset($ip['ip'])) {
continue;
}
$raw_ips[] = $ip['ip'];
}
} }
foreach ($raw_ips as $ip) { $this->debugout("IP's? ".print_r($ips,true));
if ($ip == "127.0.0.1") { foreach($ips as $ip) {
if(!isset($ip['ip'])) {
continue;
}
$raw_ips[]=$ip['ip'];
if($ip['ip'] == "127.0.0.1") {
$this->error("WARNING: Using the localhost IP as the LDAP server. This is usually wrong"); $this->error("WARNING: Using the localhost IP as the LDAP server. This is usually wrong");
} }
if (ip_in_range($ip, '10.0.0.0/8') || ip_in_range($ip, '192.168.0.0/16') || ip_in_range($ip, '172.16.0.0/12')) { if(ip_in_range($ip['ip'],'10.0.0.0/8') || ip_in_range($ip['ip'],'192.168.0.0/16') || ip_in_range($ip['ip'], '172.16.0.0/12')) {
$this->error("WARNING: Using an RFC1918 Private address for LDAP server. This may be correct, but it can be a problem if your Snipe-IT instance is not hosted on your private network"); $this->error("WARNING: Using an RFC1918 Private address for LDAP server. This may be correct, but it can be a problem if your Snipe-IT instance is not hosted on your private network");
} }
} }
$this->line("STAGE 2: Checking basic network connectivity"); $this->info("STAGE 2: Checking basic network connectivity");
$ports = [636, 389]; $ports = [389,636];
if(@$parsed['port'] && !in_array($parsed['port'],$ports)) { if(@$parsed['port'] && !in_array($parsed['port'],$ports)) {
$ports[] = $parsed['port']; $ports[] = $parsed['port'];
} }
@@ -280,7 +246,7 @@ class LdapTroubleshooter extends Command
$errstr = ''; $errstr = '';
$timeout = 30.0; $timeout = 30.0;
$result = ''; $result = '';
$this->line("Attempting to connect to port: " . $port . " - may take up to $timeout seconds"); $this->info("Attempting to connect to port: ".$port." - may take up to $timeout seconds");
try { try {
$result = fsockopen($parsed['host'], $port, $errno, $errstr, 30.0); $result = fsockopen($parsed['host'], $port, $errno, $errstr, 30.0);
} catch(Exception $e) { } catch(Exception $e) {
@@ -299,9 +265,9 @@ class LdapTroubleshooter extends Command
exit(-1); exit(-1);
} }
$this->line("STAGE 3: Determine encryption algorithm, if any"); $this->info("STAGE 3: Determine encryption algorithm, if any");
$ldap_urls = []; // [url, cert-check?, start_tls?] $ldap_urls = [];
$pretty_ldap_urls = []; $pretty_ldap_urls = [];
foreach($open_ports as $port) { foreach($open_ports as $port) {
$this->line("Trying TLS first for port $port"); $this->line("Trying TLS first for port $port");
@@ -309,46 +275,35 @@ class LdapTroubleshooter extends Command
if($this->test_anonymous_bind($ldap_url)) { if($this->test_anonymous_bind($ldap_url)) {
$this->info("Anonymous bind succesful to $ldap_url!"); $this->info("Anonymous bind succesful to $ldap_url!");
$ldap_urls[] = [ $ldap_url, true, false ]; $ldap_urls[] = [ $ldap_url, true, false ];
$pretty_ldap_urls[] = [$ldap_url, "enabled", "n/a (no)"]; $pretty_ldap_urls[] = [ $ldap_url, "YES", "no" ];
continue; // TODO - lots of copypasta in these if(test_anonymous_bind()) routines... continue; // TODO - lots of copypasta in these if(test_anonymous_bind()) routines...
} else { } else {
$this->error("WARNING: Failed to bind to $ldap_url - trying without certificate checks."); $this->error("WARNING: Failed to bind to $ldap_url - trying without certificate checks.");
} }
if($this->test_anonymous_bind($ldap_url, false)) { if($this->test_anonymous_bind($ldap_url, false)) {
$this->info("Anonymous bind successful to $ldap_url with certificate-checks disabled"); $this->info("Anonymous bind succesful to $ldap_url with certifcate-checks disabled");
$ldap_urls[] = [$ldap_url, false, false]; $ldap_urls[] = [ $ldap_url, false, false ];
$pretty_ldap_urls[] = [$ldap_url, "DISABLED", "n/a (no)"]; $pretty_ldap_urls[] = [ $ldap_url, "no", "no" ];
continue; continue;
} else { } else {
$this->error("WARNING: Failed to bind to $ldap_url with certificate checks disabled. Trying unencrypted with STARTTLS"); $this->error("WARNING: Failed to bind to $ldap_url with certificate checks disabled. Trying unencrypted with STARTTLS");
} }
// now switching to ldap:// URL's from ldaps://
$ldap_url = "ldap://".$parsed['host'].":$port"; $ldap_url = "ldap://".$parsed['host'].":$port";
if($this->test_anonymous_bind($ldap_url, true, true)) { if($this->test_anonymous_bind($ldap_url, true, true)) {
$this->info("Plain connection to $ldap_url with STARTTLS succesful!"); $this->info("Plain connection to $ldap_url with STARTTLS succesful!");
$ldap_urls[] = [ $ldap_url, true, true ]; $ldap_urls[] = [ $ldap_url, true, true ];
$pretty_ldap_urls[] = [$ldap_url, "enabled", "STARTTLS ENABLED"]; $pretty_ldap_urls[] = [ $ldap_url, "YES", "YES" ];
continue; continue;
} else { } else {
$this->error("WARNING: Failed to bind to $ldap_url with STARTTLS enabled. Trying without certificate checks."); $this->error("WARNING: Failed to bind to $ldap_url with STARTTLS enabled. Trying without STARTTLS");
}
if ($this->test_anonymous_bind($ldap_url, false, true)) {
$this->info("Plain connection to $ldap_url with STARTTLS and cert checks *disabled* successful!");
$ldap_urls[] = [$ldap_url, false, true];
$pretty_ldap_urls[] = [$ldap_url, "DISABLED", "STARTTLS ENABLED"];
continue;
} else {
$this->error("WARNING: Failed to bind to $ldap_url with STARTTLS enabled, and cert checks disabled. Trying without STARTTLS");
} }
if($this->test_anonymous_bind($ldap_url)) { if($this->test_anonymous_bind($ldap_url)) {
$this->info("Plain connection to $ldap_url succesful!"); $this->info("Plain connection to $ldap_url succesful!");
$ldap_urls[] = [ $ldap_url, true, false ]; $ldap_urls[] = [ $ldap_url, true, false ];
$pretty_ldap_urls[] = [$ldap_url, "n/a", "starttls disabled"]; $pretty_ldap_urls[] = [ $ldap_url, "YES", "no" ];
continue; continue;
} else { } else {
$this->error("WARNING: Failed to bind to $ldap_url. Giving up on port $port"); $this->error("WARNING: Failed to bind to $ldap_url. Giving up on port $port");
@@ -358,29 +313,23 @@ class LdapTroubleshooter extends Command
$this->debugout(print_r($ldap_urls,true)); $this->debugout(print_r($ldap_urls,true));
if(count($ldap_urls) > 0 ) { if(count($ldap_urls) > 0 ) {
$this->debugout("Found working LDAP URL's: "); $this->info("Found working LDAP URL's: ");
foreach($ldap_urls as $ldap_url) { // TODO maybe do this as a $this->table() instead? foreach($ldap_urls as $ldap_url) { // TODO maybe do this as a $this->table() instead?
$this->debugout("LDAP URL: " . $ldap_url[0]); $this->info("LDAP URL: ".$ldap_url[0]);
$this->debugout($ldap_url[0] . ($ldap_url[1] ? " certificate checks enabled" : " certificate checks disabled") . ($ldap_url[2] ? " STARTTLS Enabled " : " STARTTLS Disabled")); $this->info($ldap_url[0]. ($ldap_url[1] ? " certificate checks enabled" : " certificate checks disabled"). ($ldap_url[2] ? " STARTTLS Enabled ": " STARTTLS Disabled"));
} }
$this->table(["URL", "Cert Checks?", "STARTTLS?"], $pretty_ldap_urls); $this->table(["URL", "Cert Checks Enabled?", "STARTTLS Enabled?"],$pretty_ldap_urls);
} else { } else {
$this->error("ERROR - no valid LDAP URL's available - ABORTING"); $this->error("ERROR - no valid LDAP URL's available - ABORTING");
exit(1); exit(1);
} }
$this->line("STAGE 4: Test Administrative Bind for LDAP Sync"); $this->info("STAGE 4: Test Administrative Bind for LDAP Sync");
foreach($ldap_urls AS $ldap_url) { foreach($ldap_urls AS $ldap_url) {
try { $this->test_authed_bind($ldap_url[0], $ldap_url[1], $ldap_url[2], $settings->ldap_uname, Crypt::decrypt($settings->ldap_pword));
$w = Crypt::Decrypt($settings->ldap_pword);
} catch (\Exception $e) {
$this->warn("Could not decrypt password. This usually means an LDAP password was not set or the APP_KEY was changed since the LDAP pasword was last saved. Aborting.");
exit(0);
}
$this->test_authed_bind($ldap_url[0], $ldap_url[1], $ldap_url[2], $settings->ldap_uname, $w);
} }
$this->line("STAGE 5: Test BaseDN"); $this->info("STAGE 5: Test BaseDN");
//grab all LDAP_ constants and fill up a reversed array mapping from weird LDAP dotted-strings to (Constant Name) //grab all LDAP_ constants and fill up a reversed array mapping from weird LDAP dotted-strings to (Constant Name)
$all_defined_constants = get_defined_constants(); $all_defined_constants = get_defined_constants();
$ldap_constants = []; $ldap_constants = [];
@@ -392,23 +341,16 @@ class LdapTroubleshooter extends Command
$this->debugout("LDAP constants are: ".print_r($ldap_constants,true)); $this->debugout("LDAP constants are: ".print_r($ldap_constants,true));
foreach($ldap_urls AS $ldap_url) { foreach($ldap_urls AS $ldap_url) {
try { if($this->test_informational_bind($ldap_url[0],$ldap_url[1],$ldap_url[2],$settings->ldap_uname,Crypt::decrypt($settings->ldap_pword),$settings)) {
$w = Crypt::Decrypt($settings->ldap_pword);
} catch (\Exception $e) {
$this->warn("Could not decrypt password. This usually means an LDAP password was not set or the APP_KEY was changed since the LDAP pasword was last saved. Aborting.");
exit(0);
}
if($this->test_informational_bind($ldap_url[0],$ldap_url[1],$ldap_url[2],$settings->ldap_uname,$w,$settings)) {
$this->info("Success getting informational bind!"); $this->info("Success getting informational bind!");
} else { } else {
$this->error("Unable to get information from bind."); $this->error("Unable to get information from bind.");
} }
} }
$this->line("STAGE 6: Test LDAP Login to Snipe-IT"); $this->info("STAGE 6: Test LDAP Login to Snipe-IT");
foreach($ldap_urls AS $ldap_url) { foreach($ldap_urls AS $ldap_url) {
$this->line("Starting auth to " . $ldap_url[0]); $this->info("Starting auth to ".$ldap_url[0]);
while(true) { while(true) {
$with_tls = $ldap_url[1] ? "with": "without"; $with_tls = $ldap_url[1] ? "with": "without";
$with_startssl = $ldap_url[2] ? "using": "not using"; $with_startssl = $ldap_url[2] ? "using": "not using";
@@ -417,12 +359,7 @@ class LdapTroubleshooter extends Command
} }
$username = $this->ask("Username"); $username = $this->ask("Username");
$password = $this->secret("Password"); $password = $this->secret("Password");
$results = $this->test_authed_bind($ldap_url[0], $ldap_url[1], $ldap_url[2], $username, $password); // FIXME - should do some other stuff here, maybe with the concatenating or something? maybe? and/or should put up some results? $this->test_authed_bind($ldap_url[0], $ldap_url[1], $ldap_url[2], $username, $password); // FIXME - should do some other stuff here, maybe with the concatenating or something? maybe? and/or should put up some results?
if ($results) {
$this->info("Success authenticating with " . $username);
} else {
$this->error("Unable to authenticate with " . $username);
}
} }
} }
@@ -431,17 +368,14 @@ class LdapTroubleshooter extends Command
public function connect_to_ldap($ldap_url, $check_cert, $start_tls) public function connect_to_ldap($ldap_url, $check_cert, $start_tls)
{ {
if ($check_cert) {
$this->line("we *ARE* checking certs");
Ldap::ignoreCertificates(false);
} else {
$this->line("we are IGNORING certs");
Ldap::ignoreCertificates(true);
}
$lconn = ldap_connect($ldap_url); $lconn = ldap_connect($ldap_url);
ldap_set_option($lconn, LDAP_OPT_PROTOCOL_VERSION, 3); // should we 'test' different protocol versions here? Does anyone even use anything other than LDAPv3? ldap_set_option($lconn, LDAP_OPT_PROTOCOL_VERSION, 3); // should we 'test' different protocol versions here? Does anyone even use anything other than LDAPv3?
// no - it's formally deprecated: https://tools.ietf.org/html/rfc3494 // no - it's formally deprecated: https://tools.ietf.org/html/rfc3494
if(!$check_cert) {
putenv('LDAPTLS_REQCERT=never'); // This is horrible; is this *really* the only way to do it?
} else {
putenv('LDAPTLS_REQCERT'); // have to very explicitly and manually *UN* set the env var here to ensure it works
}
if($this->settings->ldap_client_tls_cert && $this->settings->ldap_client_tls_key) { if($this->settings->ldap_client_tls_cert && $this->settings->ldap_client_tls_key) {
// client-side TLS certificate support for LDAP (Google Secure LDAP) // client-side TLS certificate support for LDAP (Google Secure LDAP)
putenv('LDAPTLS_CERT=storage/ldap_client_tls.cert'); putenv('LDAPTLS_CERT=storage/ldap_client_tls.cert');
@@ -470,10 +404,9 @@ class LdapTroubleshooter extends Command
return $this->timed_boolean_execute(function () use ($ldap_url, $check_cert , $start_tls) { return $this->timed_boolean_execute(function () use ($ldap_url, $check_cert , $start_tls) {
try { try {
$lconn = $this->connect_to_ldap($ldap_url, $check_cert, $start_tls); $lconn = $this->connect_to_ldap($ldap_url, $check_cert, $start_tls);
$this->line("Attempting to bind now, this can take a while if we mess it up"); $this->info("gonna try to bind now, this can take a while if we mess it up");
$bind_results = ldap_bind($lconn); $bind_results = ldap_bind($lconn);
$this->line("Bind results are: " . $bind_results . " which translate into boolean: " . (bool)$bind_results); $this->info("Bind results are: ".$bind_results." which translate into boolean: ".(bool)$bind_results);
ldap_close($lconn);
return (bool)$bind_results; return (bool)$bind_results;
} catch (Exception $e) { } catch (Exception $e) {
$this->error("WARNING: Exception caught during bind - ".$e->getMessage()); $this->error("WARNING: Exception caught during bind - ".$e->getMessage());
@@ -488,7 +421,6 @@ class LdapTroubleshooter extends Command
try { try {
$lconn = $this->connect_to_ldap($ldap_url, $check_cert, $start_tls); $lconn = $this->connect_to_ldap($ldap_url, $check_cert, $start_tls);
$bind_results = ldap_bind($lconn, $username, $password); $bind_results = ldap_bind($lconn, $username, $password);
ldap_close($lconn);
if(!$bind_results) { if(!$bind_results) {
$this->error("WARNING: Failed to bind to $ldap_url as $username"); $this->error("WARNING: Failed to bind to $ldap_url as $username");
return false; return false;
@@ -514,62 +446,22 @@ class LdapTroubleshooter extends Command
return false; return false;
} }
$this->info("SUCCESS - Able to bind to $ldap_url as $username"); $this->info("SUCCESS - Able to bind to $ldap_url as $username");
$cleaned_results = []; $result = ldap_read($conn, '', '(objectClass=*)'/* , ['supportedControl']*/);
try { $results = ldap_get_entries($conn, $result);
// This _may_ only work for Active Directory? $cleaned_results = $this->ldap_results_cleaner($results);
$result = ldap_read($conn, '', '(objectClass=*)'/* , ['supportedControl']*/); $this->line(print_r($cleaned_results,true));
$results = ldap_get_entries($conn, $result); //okay, great - now how do we display those results? I have no idea.
$cleaned_results = $this->ldap_results_cleaner($results);
//$this->line(print_r($cleaned_results,true));
$default_naming_contexts = $cleaned_results[0]['namingcontexts'];
$this->info("Default Naming Contexts:");
$this->info(implode(", ", $default_naming_contexts));
//okay, great - now how do we display those results? I have no idea.
} catch (\Exception $e) {
$this->error("Unable to get base naming contexts - here's what we *did* get:");
$this->line(print_r($cleaned_results, true));
}
// I don't see why this throws an Exception for Google LDAP, but I guess we ought to try and catch it? // I don't see why this throws an Exception for Google LDAP, but I guess we ought to try and catch it?
$this->debugout("I guess we're trying to do the ldap search here, but sometimes it takes too long?"); $this->comment("I guess we're trying to do the ldap search here, but sometimes it takes too long?");
$this->debugout("Base DN is: ".$settings->ldap_basedn." and filter is: ".parenthesized_filter($settings->ldap_filter)); $this->debugout("Base DN is: ".$settings->ldap_basedn." and filter is: ".parenthesized_filter($settings->ldap_filter));
$search_results = ldap_search($conn, $settings->ldap_basedn, parenthesized_filter($settings->ldap_filter)); $search_results = ldap_search($conn, $settings->ldap_basedn, parenthesized_filter($settings->ldap_filter));
$entries = ldap_get_entries($conn, $search_results);
$this->info("Printing first 10 results: "); $this->info("Printing first 10 results: ");
$pretty_data = array_slice($this->ldap_results_cleaner($entries), 0, 10); for($i=0;$i<10;$i++) {
//print_r($data); $this->info($search_results[$i]);
$headers = [];
foreach ($pretty_data as $row) {
//populate headers
foreach ($row as $key => $value) {
//skip objectsid and objectguid because it junks up output
if ($key == "objectsid" || $key == "objectguid") {
continue;
}
if (!in_array($key, $headers)) {
$headers[] = $key;
}
}
} }
$table = [];
//repeat again to populate table
foreach ($pretty_data as $row) {
$newrow = [];
foreach ($headers as $header) {
if (is_array(@$row[$header])) {
$newrow[] = "[" . implode(", ", $row[$header]) . "]";
} else {
$newrow[] = @$row[$header];
}
}
$table[] = $newrow;
}
$this->table($headers, $table);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->error("WARNING: Exception caught during Authed bind to $username - ".$e->getMessage()); $this->error("WARNING: Exception caught during Authed bind to $username - ".$e->getMessage());
return false; return false;
} finally {
ldap_close($conn);
} }
}); });
} }
@@ -585,7 +477,7 @@ class LdapTroubleshooter extends Command
{ {
if(!(function_exists('pcntl_sigtimedwait') && function_exists('posix_getpid') && function_exists('pcntl_fork') && function_exists('posix_kill') && function_exists('pcntl_wifsignaled'))) { if(!(function_exists('pcntl_sigtimedwait') && function_exists('posix_getpid') && function_exists('pcntl_fork') && function_exists('posix_kill') && function_exists('pcntl_wifsignaled'))) {
// POSIX functions needed for forking aren't present, just run the function inline (ignoring timeout) // POSIX functions needed for forking aren't present, just run the function inline (ignoring timeout)
$this->line('WARNING: Unable to execute POSIX fork() commands, timeout may not be respected'); $this->info('WARNING: Unable to execute POSIX fork() commands, timeout may not be respected');
return $function(); return $function();
} else { } else {
$parent_pid = posix_getpid(); $parent_pid = posix_getpid();
@@ -622,6 +514,4 @@ class LdapTroubleshooter extends Command
} }
} }
} }
@@ -96,7 +96,7 @@ class MoveUploadsToNewDisk extends Command
$private_uploads['assets'] = glob('storage/private_uploads/assets'."/*.*"); $private_uploads['assets'] = glob('storage/private_uploads/assets'."/*.*");
$private_uploads['signatures'] = glob('storage/private_uploads/signatures'."/*.*"); $private_uploads['signatures'] = glob('storage/private_uploads/signatures'."/*.*");
$private_uploads['audits'] = glob('storage/private_uploads/audits'."/*.*"); $private_uploads['audits'] = glob('storage/private_uploads/audits'."/*.*");
$private_uploads['assetmodels'] = glob('storage/private_uploads/models'."/*.*"); $private_uploads['assetmodels'] = glob('storage/private_uploads/assetmodels'."/*.*");
$private_uploads['imports'] = glob('storage/private_uploads/imports'."/*.*"); $private_uploads['imports'] = glob('storage/private_uploads/imports'."/*.*");
$private_uploads['licenses'] = glob('storage/private_uploads/licenses'."/*.*"); $private_uploads['licenses'] = glob('storage/private_uploads/licenses'."/*.*");
$private_uploads['users'] = glob('storage/private_uploads/users'."/*.*"); $private_uploads['users'] = glob('storage/private_uploads/users'."/*.*");
+33 -18
View File
@@ -6,7 +6,6 @@ use Illuminate\Console\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Symfony\Component\Console\Helper\ProgressIndicator;
ini_set('max_execution_time', env('IMPORT_TIME_LIMIT', 600)); //600 seconds = 10 minutes ini_set('max_execution_time', env('IMPORT_TIME_LIMIT', 600)); //600 seconds = 10 minutes
ini_set('memory_limit', env('IMPORT_MEMORY_LIMIT', '500M')); ini_set('memory_limit', env('IMPORT_MEMORY_LIMIT', '500M'));
@@ -30,11 +29,6 @@ class ObjectImportCommand extends Command
*/ */
protected $description = 'Import Items from CSV'; protected $description = 'Import Items from CSV';
/**
* The progress indicator instance.
*/
protected ProgressIndicator $progressIndicator;
/** /**
* Create a new command instance. * Create a new command instance.
* *
@@ -45,6 +39,8 @@ class ObjectImportCommand extends Command
parent::__construct(); parent::__construct();
} }
private $bar;
/** /**
* Execute the console command. * Execute the console command.
* *
@@ -52,14 +48,12 @@ class ObjectImportCommand extends Command
*/ */
public function handle() public function handle()
{ {
$this->progressIndicator = new ProgressIndicator($this->output);
$filename = $this->argument('filename'); $filename = $this->argument('filename');
$class = title_case($this->option('item-type')); $class = title_case($this->option('item-type'));
$classString = "App\\Importer\\{$class}Importer"; $classString = "App\\Importer\\{$class}Importer";
$importer = new $classString($filename); $importer = new $classString($filename);
$importer->setCallbacks([$this, 'log'], [$this, 'progress'], [$this, 'errorCallback']) $importer->setCallbacks([$this, 'log'], [$this, 'progress'], [$this, 'errorCallback'])
->setCreatedBy($this->option('user_id')) ->setUserId($this->option('user_id'))
->setUpdating($this->option('update')) ->setUpdating($this->option('update'))
->setShouldNotify($this->option('send-welcome')) ->setShouldNotify($this->option('send-welcome'))
->setUsernameFormat($this->option('username_format')); ->setUsernameFormat($this->option('username_format'));
@@ -67,25 +61,46 @@ class ObjectImportCommand extends Command
// This $logFile/useFiles() bit is currently broken, so commenting it out for now // This $logFile/useFiles() bit is currently broken, so commenting it out for now
// $logFile = $this->option('logfile'); // $logFile = $this->option('logfile');
// Log::useFiles($logFile); // Log::useFiles($logFile);
$this->progressIndicator->start('======= Importing Items from '.$filename.' ========='); $this->comment('======= Importing Items from '.$filename.' =========');
$importer->import(); $importer->import();
$this->progressIndicator->finish('Import finished.'); $this->bar = null;
if (! empty($this->errors)) {
$this->comment('The following Errors were encountered.');
foreach ($this->errors as $asset => $error) {
$this->comment('Error: Item: '.$asset.' failed validation: '.json_encode($error));
}
} else {
$this->comment('All Items imported successfully!');
}
$this->comment('');
} }
public function errorCallback($item, $field, $error) public function errorCallback($item, $field, $errorString)
{ {
$this->output->write("\x0D\x1B[2K"); $this->errors[$item->name][$field] = $errorString;
$this->warn('Error: Item: '.$item->name.' failed validation: '.json_encode($error));
} }
public function progress($importedItemsCount) public function progress($count)
{ {
$this->progressIndicator->advance(); if (! $this->bar) {
$this->bar = $this->output->createProgressBar($count);
}
static $index = 0;
$index++;
if ($index < $count) {
$this->bar->advance();
} else {
$this->bar->finish();
}
} }
// Tracks the current item for error messages
private $updating;
// An array of errors encountered while parsing
private $errors;
/** /**
* Log a message to file, configurable by the --log-file parameter. * Log a message to file, configurable by the --log-file parameter.
* If a warning message is passed, we'll spit it to the console as well. * If a warning message is passed, we'll spit it to the console as well.
+8 -11
View File
@@ -4,7 +4,7 @@ namespace App\Console\Commands;
use App\Models\Asset; use App\Models\Asset;
use App\Models\CustomField; use App\Models\CustomField;
use Illuminate\Support\Facades\Schema; use Schema;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@@ -51,7 +51,8 @@ class PaveIt extends Command
} }
// List all the tables in the database so we don't have to worry about missing some as the app grows // List all the tables in the database so we don't have to worry about missing some as the app grows
$tables = Schema::getTables(); $tables = DB::connection()->getDoctrineSchemaManager()->listTableNames();
$except_tables = [ $except_tables = [
'oauth_access_tokens', 'oauth_access_tokens',
'oauth_clients', 'oauth_clients',
@@ -59,9 +60,6 @@ class PaveIt extends Command
'migrations', 'migrations',
'settings', 'settings',
'users', 'users',
'telescope_entries',
'telescope_entries_tags',
'telescope_monitoring',
]; ];
// We only need to find out what these are so we can nuke these columns on the assets table. // We only need to find out what these are so we can nuke these columns on the assets table.
@@ -69,15 +67,14 @@ class PaveIt extends Command
foreach ($custom_fields as $custom_field) { foreach ($custom_fields as $custom_field) {
$this->info('DROP the '.$custom_field->db_column.' column from assets as well.'); $this->info('DROP the '.$custom_field->db_column.' column from assets as well.');
if (Schema::hasColumn('assets', $custom_field->db_column)) { if (\Schema::hasColumn('assets', $custom_field->db_column)) {
Schema::table('assets', function ($table) use ($custom_field) { \Schema::table('assets', function ($table) use ($custom_field) {
$table->dropColumn($custom_field->db_column); $table->dropColumn($custom_field->db_column);
}); });
} }
} }
foreach ($tables as $table_obj) { foreach ($tables as $table) {
$table = $table_obj['name'];
if (in_array($table, $except_tables)) { if (in_array($table, $except_tables)) {
$this->info($table. ' is SKIPPED.'); $this->info($table. ' is SKIPPED.');
} else { } else {
@@ -87,8 +84,8 @@ class PaveIt extends Command
} }
// Leave in the demo oauth keys so we don't have to reset them every day in the demos // Leave in the demo oauth keys so we don't have to reset them every day in the demos
DB::statement('delete from oauth_clients WHERE id > 2'); \DB::statement('delete from oauth_clients WHERE id > 2');
DB::statement('delete from oauth_access_tokens WHERE user_id > 2'); \DB::statement('delete from oauth_access_tokens WHERE id > 2');
} }
} }
+5 -22
View File
@@ -3,7 +3,6 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Accessory; use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Asset; use App\Models\Asset;
use App\Models\AssetModel; use App\Models\AssetModel;
use App\Models\Category; use App\Models\Category;
@@ -16,8 +15,6 @@ use App\Models\Statuslabel;
use App\Models\Supplier; use App\Models\Supplier;
use App\Models\User; use App\Models\User;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class Purge extends Command class Purge extends Command
{ {
@@ -62,19 +59,19 @@ class Purge extends Command
$assetcount = $assets->count(); $assetcount = $assets->count();
$this->info($assets->count().' assets purged.'); $this->info($assets->count().' assets purged.');
$asset_assoc = 0; $asset_assoc = 0;
$maintenances = 0; $asset_maintenances = 0;
foreach ($assets as $asset) { foreach ($assets as $asset) {
$this->info('- Asset "'.$asset->display_name.'" deleted.'); $this->info('- Asset "'.$asset->present()->name().'" deleted.');
$asset_assoc += $asset->assetlog()->count(); $asset_assoc += $asset->assetlog()->count();
$asset->assetlog()->forceDelete(); $asset->assetlog()->forceDelete();
$maintenances += $asset->maintenances()->count(); $asset_maintenances += $asset->assetmaintenances()->count();
$asset->maintenances()->forceDelete(); $asset->assetmaintenances()->forceDelete();
$asset->forceDelete(); $asset->forceDelete();
} }
$this->info($asset_assoc.' corresponding log records purged.'); $this->info($asset_assoc.' corresponding log records purged.');
$this->info($maintenances.' corresponding maintenance records purged.'); $this->info($asset_maintenances.' corresponding maintenance records purged.');
$locations = Location::whereNotNull('deleted_at')->withTrashed()->get(); $locations = Location::whereNotNull('deleted_at')->withTrashed()->get();
$this->info($locations->count().' locations purged.'); $this->info($locations->count().' locations purged.');
@@ -144,20 +141,6 @@ class Purge extends Command
$this->info($users->count().' users purged.'); $this->info($users->count().' users purged.');
$user_assoc = 0; $user_assoc = 0;
foreach ($users as $user) { foreach ($users as $user) {
$rel_path = 'private_uploads/users';
$filenames = Actionlog::where('action_type', 'uploaded')
->where('item_id', $user->id)
->pluck('filename');
foreach($filenames as $filename) {
try {
if (Storage::exists($rel_path . '/' . $filename)) {
Storage::delete($rel_path . '/' . $filename);
}
} catch (\Exception $e) {
Log::info('An error occurred while deleting files: ' . $e->getMessage());
}
}
$this->info('- User "'.$user->username.'" deleted.'); $this->info('- User "'.$user->username.'" deleted.');
$user_assoc += $user->userlog()->count(); $user_assoc += $user->userlog()->count();
$user->userlog()->forceDelete(); $user->userlog()->forceDelete();
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace App\Console\Commands;
use App\LegacyEncrypter\McryptEncrypter;
use App\Models\Asset;
use App\Models\CustomField;
use App\Models\Setting;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class RecryptFromMcrypt extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'snipeit:legacy-recrypt
{--force : Force a re-crypt of encrypted data from MCRYPT.}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'This command allows upgrading users to de-encrypt their deprecated mcrypt encrypted fields and re-encrypt them using the current OpenSSL encryption.';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
// Check and see if they have a legacy app key listed in their .env
// If not, we can try to use the current APP_KEY if looks like it's old
$legacy_key = env('LEGACY_APP_KEY');
$key_parts = explode(':', $legacy_key);
$legacy_cipher = env('LEGACY_CIPHER', 'rijndael-256');
$errors = [];
if (! $legacy_key) {
$this->error('ERROR: You do not have a LEGACY_APP_KEY set in your .env file. Please locate your old APP_KEY and ADD a line to your .env file like: LEGACY_APP_KEY=YOUR_OLD_APP_KEY');
return false;
}
// Do some basic legacy app key length checks
if (strlen($legacy_key) == 32) {
$legacy_length_check = true;
} elseif (array_key_exists('1', $key_parts) && (strlen($key_parts[1]) == 44)) {
$legacy_key = base64_decode($key_parts[1], true);
$legacy_length_check = true;
} else {
$legacy_length_check = false;
}
// Check that the app key is 32 characters
if ($legacy_length_check === true) {
$this->comment('INFO: Your LEGACY_APP_KEY looks correct. Okay to continue.');
} else {
$this->error('ERROR: Your LEGACY_APP_KEY is not the correct length (32 characters or base64 followed by 44 characters for later versions). Please locate your old APP_KEY and use that as your LEGACY_APP_KEY in your .env file to continue.');
return false;
}
$this->error('================================!!!! WARNING !!!!================================');
$this->error('================================!!!! WARNING !!!!================================');
$this->comment("This tool will attempt to decrypt your old Snipe-IT (mcrypt, now deprecated) encrypted data and re-encrypt it using OpenSSL. \n\nYou should only continue if you have backed up any and all old APP_KEYs and have backed up your data.");
$force = ($this->option('force')) ? true : false;
if ($force || ($this->confirm('Are you SURE you wish to continue?'))) {
$backup_file = 'backups/env-backups/'.'app_key-'.date('Y-m-d-gis');
try {
Storage::disk('local')->put($backup_file, 'APP_KEY: '.config('app.key'));
Storage::disk('local')->append($backup_file, 'LEGACY_APP_KEY: '.$legacy_key);
} catch (\Exception $e) {
$this->info('WARNING: Could not backup app keys');
}
if ($legacy_cipher) {
$mcrypter = new McryptEncrypter($legacy_key, $legacy_cipher);
} else {
$mcrypter = new McryptEncrypter($legacy_key);
}
$settings = Setting::getSettings();
if ($settings->ldap_pword == '') {
$this->comment('INFO: No LDAP password found. Skipping... ');
} else {
$decrypted_ldap_pword = $mcrypter->decrypt($settings->ldap_pword);
$settings->ldap_pword = Crypt::encrypt($decrypted_ldap_pword);
$settings->save();
}
/** @var CustomField[] $custom_fields */
$custom_fields = CustomField::where('field_encrypted', '=', 1)->get();
$this->comment('INFO: Retrieving encrypted custom fields...');
$query = Asset::withTrashed();
foreach ($custom_fields as $custom_field) {
$this->comment('FIELD TO RECRYPT: '.$custom_field->name.' ('.$custom_field->db_column.')');
$query->orWhereNotNull($custom_field->db_column);
}
// Get all assets with a value in any of the fields that were encrypted
/** @var Asset[] $assets */
$assets = $query->get();
$bar = $this->output->createProgressBar(count($assets));
foreach ($assets as $asset) {
foreach ($custom_fields as $encrypted_field) {
$columnName = $encrypted_field->db_column;
// Make sure the value isn't null
if ($asset->{$columnName} != '') {
// Try to decrypt the payload using the legacy app key
try {
$decrypted_field = $mcrypter->decrypt($asset->{$columnName});
$asset->{$columnName} = Crypt::encrypt($decrypted_field);
$this->comment($decrypted_field);
} catch (\Exception $e) {
$errors[] = ' - ERROR: Could not decrypt field ['.$encrypted_field->name.']: '.$e->getMessage();
}
}
}
$asset->save();
$bar->advance();
}
$bar->finish();
if (count($errors) > 0) {
$this->comment("\n\n");
$this->error("The decrypter encountered some errors: \n");
foreach ($errors as $error) {
$this->error($error);
}
}
}
}
}
@@ -1,60 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Asset;
use App\Models\AssetModel;
use Illuminate\Console\Command;
class RemoveExplicitEols extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'snipeit:remove-explicit-eols {--model_name= : The name of the asset model to update (use "all" to update all models)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Removes explicit EOLs on assets with selected model so they may inherit the asset model EOL';
/**
* Execute the console command.
*/
public function handle()
{
$startTime = microtime(true);
if ($this->option('model_name') == 'all') {
$assets = Asset::all();
$this->updateAssets($assets);
} else {
$assetModel = AssetModel::where('name', '=', $this->option('model_name'))->first();
if ($assetModel) {
$assets = Asset::where('model_id', '=', $assetModel->id)->get();
$this->updateAssets($assets);
} else {
$this->error('Asset model not found');
}
}
$endTime = microtime(true);
$executionTime = ($endTime - $startTime);
$this->info('Command executed in ' . round($executionTime, 2) . ' seconds.');
}
private function updateAssets($assets)
{
foreach ($assets as $asset) {
$asset->eol_explicit = 0;
$asset->asset_eol_date = null;
$asset->save();
}
$this->info($assets->count() . ' Assets updated successfully');
}
}
+3 -4
View File
@@ -50,12 +50,12 @@ class ResetDemoSettings extends Command
$settings->alert_email = 'service@snipe-it.io'; $settings->alert_email = 'service@snipe-it.io';
$settings->login_note = 'Use `admin` / `password` to login to the demo.'; $settings->login_note = 'Use `admin` / `password` to login to the demo.';
$settings->header_color = null; $settings->header_color = null;
$settings->label2_2d_type = 'QRCODE'; $settings->barcode_type = 'QRCODE';
$settings->default_currency = 'USD'; $settings->default_currency = 'USD';
$settings->brand = 2; $settings->brand = 2;
$settings->ldap_enabled = 0; $settings->ldap_enabled = 0;
$settings->full_multiple_companies_support = 0; $settings->full_multiple_companies_support = 0;
$settings->label2_1d_type = 'C128'; $settings->alt_barcode = 'C128';
$settings->skin = ''; $settings->skin = '';
$settings->email_domain = 'snipeitapp.com'; $settings->email_domain = 'snipeitapp.com';
$settings->email_format = 'filastname'; $settings->email_format = 'filastname';
@@ -65,7 +65,7 @@ class ResetDemoSettings extends Command
$settings->thumbnail_max_h = '30'; $settings->thumbnail_max_h = '30';
$settings->locale = 'en-US'; $settings->locale = 'en-US';
$settings->version_footer = 'on'; $settings->version_footer = 'on';
$settings->support_footer = 'on'; $settings->support_footer = null;
$settings->saml_enabled = '0'; $settings->saml_enabled = '0';
$settings->saml_sp_x509cert = null; $settings->saml_sp_x509cert = null;
$settings->saml_idp_metadata = null; $settings->saml_idp_metadata = null;
@@ -73,7 +73,6 @@ class ResetDemoSettings extends Command
$settings->saml_forcelogin = '0'; $settings->saml_forcelogin = '0';
$settings->saml_slo = null; $settings->saml_slo = null;
$settings->saml_custom_settings = null; $settings->saml_custom_settings = null;
$settings->default_avatar = 'default.png';
$settings->save(); $settings->save();
+11 -47
View File
@@ -30,11 +30,8 @@ class SQLStreamer {
public function parse_sql(string $line): string { public function parse_sql(string $line): string {
// take into account the 'start of line or not' setting as an instance variable? // take into account the 'start of line or not' setting as an instance variable?
// 'continuation' lines for a permitted statement are PERMITTED. // 'continuation' lines for a permitted statement are PERMITTED.
// remove *only* line-feeds & carriage-returns; helpful for regexes against lines from
// Windows dumps
$line = trim($line, "\r\n");
if($this->statement_is_permitted && $line[0] === ' ') { if($this->statement_is_permitted && $line[0] === ' ') {
return $line . "\n"; //re-add the newline return $line;
} }
$table_regex = '`?([a-zA-Z0-9_]+)`?'; $table_regex = '`?([a-zA-Z0-9_]+)`?';
@@ -45,14 +42,8 @@ class SQLStreamer {
"/^(INSERT INTO )$table_regex(.*)$/" => false, "/^(INSERT INTO )$table_regex(.*)$/" => false,
"/^UNLOCK TABLES/" => false, "/^UNLOCK TABLES/" => false,
// "/^\\) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;/" => false, // FIXME not sure what to do here? // "/^\\) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;/" => false, // FIXME not sure what to do here?
"/^\\)[a-zA-Z0-9_= ]*;$/" => false, "/^\\)[a-zA-Z0-9_= ]*;$/" => false
// ^^^^^^ that bit should *exit* the 'permitted' block // ^^^^^^ that bit should *exit* the 'perimitted' black
"/^\\(.*\\)[,;]$/" => false, //older MySQL dump style with one set of values per line
/* we *could* have made the ^INSERT INTO blah VALUES$ turn on the capturing state, and closed it with
a ^(blahblah);$ but it's cleaner to not have to manage the state machine. We're just going to
assume that (blahblah), or (blahblah); are values for INSERT and are always acceptable. */
"<^/\*!40101 SET NAMES '?[a-zA-Z0-9_-]+'? \*/;$>" => false, //using weird delimiters (<,>) for readability. allow quoted or unquoted charsets
"<^/\*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' \*/;$>" => false, //same, now handle zero-values
]; ];
foreach($allowed_statements as $statement => $statechange) { foreach($allowed_statements as $statement => $statechange) {
@@ -76,7 +67,7 @@ class SQLStreamer {
} }
//how do we *replace* the tablename? //how do we *replace* the tablename?
// print "RETURNING LINE: $line"; // print "RETURNING LINE: $line";
return $line . "\n"; //re-add newline return $line;
} }
} }
// all that is not allowed is denied. // all that is not allowed is denied.
@@ -94,7 +85,7 @@ class SQLStreamer {
$parser->line_aware_piping(); // <----- THIS is doing the heavy lifting! $parser->line_aware_piping(); // <----- THIS is doing the heavy lifting!
$check_tables = ['settings' => null, 'migrations' => null /* 'assets' => null */]; //TODO - move to statics? $check_tables = ['settings' => null, 'migrations' => null /* 'assets' => null */]; //TODO - move to statics?
//can't use 'users' because the 'accessories_checkout' table? //can't use 'users' because the 'accessories_users' table?
// can't use 'assets' because 'ver1_components_assets' // can't use 'assets' because 'ver1_components_assets'
foreach($check_tables as $check_table => $_ignore) { foreach($check_tables as $check_table => $_ignore) {
foreach ($parser->tablenames as $tablename => $_count) { foreach ($parser->tablenames as $tablename => $_count) {
@@ -173,8 +164,7 @@ class RestoreFromBackup extends Command
{filename : The zip file to be migrated} {filename : The zip file to be migrated}
{--no-progress : Don\'t show a progress bar} {--no-progress : Don\'t show a progress bar}
{--sanitize-guess-prefix : Guess and output the table-prefix needed to "sanitize" the SQL} {--sanitize-guess-prefix : Guess and output the table-prefix needed to "sanitize" the SQL}
{--sanitize-with-prefix= : "Sanitize" the SQL, using the passed-in table prefix (can be learned from --sanitize-guess-prefix). Pass as just \'--sanitize-with-prefix=\' to use no prefix} {--sanitize-with-prefix= : "Sanitize" the SQL, using the passed-in table prefix (can be learned from --sanitize-guess-prefix). Pass as just \'--sanitize-with-prefix=\' to use no prefix}';
{--sql-stdout-only : ONLY "Sanitize" the SQL and print it to stdout - useful for debugging - probably requires --sanitize-with-prefix= }';
/** /**
* The console command description. * The console command description.
@@ -243,15 +233,12 @@ class RestoreFromBackup extends Command
$private_dirs = [ $private_dirs = [
'storage/private_uploads/accessories', 'storage/private_uploads/accessories',
'storage/private_uploads/assetmodels', 'storage/private_uploads/assetmodels',
'storage/private_uploads/maintenances',
'storage/private_uploads/models',
'storage/private_uploads/assets', // these are asset _files_, not the pictures. 'storage/private_uploads/assets', // these are asset _files_, not the pictures.
'storage/private_uploads/audits', 'storage/private_uploads/audits',
'storage/private_uploads/components', 'storage/private_uploads/components',
'storage/private_uploads/consumables', 'storage/private_uploads/consumables',
'storage/private_uploads/eula-pdfs', 'storage/private_uploads/eula-pdfs',
'storage/private_uploads/imports', 'storage/private_uploads/imports',
'storage/private_uploads/locations',
'storage/private_uploads/licenses', 'storage/private_uploads/licenses',
'storage/private_uploads/signatures', 'storage/private_uploads/signatures',
'storage/private_uploads/users', 'storage/private_uploads/users',
@@ -262,10 +249,9 @@ class RestoreFromBackup extends Command
]; ];
$public_dirs = [ $public_dirs = [
'public/uploads/accessories', 'public/uploads/accessories',
'public/uploads/assetmodels',
'public/uploads/maintenances',
'public/uploads/assets', // these are asset _pictures_, not asset files 'public/uploads/assets', // these are asset _pictures_, not asset files
'public/uploads/avatars', 'public/uploads/avatars',
//'public/uploads/barcodes', // we don't want this, let the barcodes be regenerated
'public/uploads/categories', 'public/uploads/categories',
'public/uploads/companies', 'public/uploads/companies',
'public/uploads/components', 'public/uploads/components',
@@ -293,7 +279,6 @@ class RestoreFromBackup extends Command
$interesting_files = []; $interesting_files = [];
$boring_files = []; $boring_files = [];
$unsafe_files = [];
for ($i = 0; $i < $za->numFiles; $i++) { for ($i = 0; $i < $za->numFiles; $i++) {
$stat_results = $za->statIndex($i); $stat_results = $za->statIndex($i);
@@ -332,9 +317,8 @@ class RestoreFromBackup extends Command
} }
} }
} }
$good_extensions = ['png', 'gif', 'jpg', 'svg', 'jpeg', 'doc', 'docx', 'pdf', 'txt',
$good_extensions = config('filesystems.allowed_upload_extensions_array'); 'zip', 'rar', 'xls', 'xlsx', 'lic', 'xml', 'rtf', 'webp', 'key', 'ico',];
foreach (array_merge($private_files, $public_files) as $file) { foreach (array_merge($private_files, $public_files) as $file) {
$has_wildcard = (strpos($file, '*') !== false); $has_wildcard = (strpos($file, '*') !== false);
if ($has_wildcard) { if ($has_wildcard) {
@@ -344,9 +328,7 @@ class RestoreFromBackup extends Command
if ($last_pos !== false) { if ($last_pos !== false) {
$extension = strtolower(pathinfo($raw_path, PATHINFO_EXTENSION)); $extension = strtolower(pathinfo($raw_path, PATHINFO_EXTENSION));
if (!in_array($extension, $good_extensions)) { if (!in_array($extension, $good_extensions)) {
// gathering potentially unsafe files here to return at exit $this->warn('Potentially unsafe file ' . $raw_path . ' is being skipped');
$unsafe_files[] = $raw_path;
Log::debug('Potentially unsafe file '.$raw_path.' is being skipped');
$boring_files[] = $raw_path; $boring_files[] = $raw_path;
continue 2; continue 2;
} }
@@ -380,17 +362,7 @@ class RestoreFromBackup extends Command
if ($this->option('sanitize-guess-prefix')) { if ($this->option('sanitize-guess-prefix')) {
$prefix = SQLStreamer::guess_prefix($sql_contents); $prefix = SQLStreamer::guess_prefix($sql_contents);
$this->line($prefix); $this->line($prefix);
return $this->info("Re-run this command with '--sanitize-with-prefix=".$prefix."' to see an attempt to sanitze your SQL.");
return $this->info("Re-run this command with '--sanitize-with-prefix=".$prefix."' to see an attempt to sanitize your SQL.");
}
// If we're doing --sql-stdout-only, handle that now so we don't have to open pipes to mysql and all of that silliness
if ($this->option('sql-stdout-only')) {
$sql_importer = new SQLStreamer($sql_contents, STDOUT, $this->option('sanitize-with-prefix'));
$bytes_read = $sql_importer->line_aware_piping();
return $this->warn("$bytes_read total bytes read");
//TODO - it'd be nice to dump this message to STDERR so that STDOUT is just pure SQL,
// which would be good for redirecting to a file, and not having to trim the last line off of it
} }
//how to invoke the restore? //how to invoke the restore?
@@ -494,9 +466,6 @@ class RestoreFromBackup extends Command
$ugly_file_name = $za->statIndex($file_details['index'])['name']; $ugly_file_name = $za->statIndex($file_details['index'])['name'];
$fp = $za->getStream($ugly_file_name); $fp = $za->getStream($ugly_file_name);
//$this->info("Weird problem, here are file details? ".print_r($file_details,true)); //$this->info("Weird problem, here are file details? ".print_r($file_details,true));
if (!is_dir($file_details['dest'])) {
mkdir($file_details['dest'], 0755, true); //0755 is what Laravel uses, so we do that
}
$migrated_file = fopen($file_details['dest'].'/'.basename($pretty_file_name), 'w'); $migrated_file = fopen($file_details['dest'].'/'.basename($pretty_file_name), 'w');
while (($buffer = fgets($fp, SQLStreamer::$buffer_size)) !== false) { while (($buffer = fgets($fp, SQLStreamer::$buffer_size)) !== false) {
fwrite($migrated_file, $buffer); fwrite($migrated_file, $buffer);
@@ -514,11 +483,6 @@ class RestoreFromBackup extends Command
} else { } else {
$this->info(count($interesting_files).' files were succesfully transferred'); $this->info(count($interesting_files).' files were succesfully transferred');
} }
if (count($unsafe_files) > 0) {
foreach ($unsafe_files as $unsafe_file) {
$this->warn('Potentially unsafe file '.$unsafe_file.' was skipped');
}
}
foreach ($boring_files as $boring_file) { foreach ($boring_files as $boring_file) {
$this->warn($boring_file.' was skipped.'); $this->warn($boring_file.' was skipped.');
} }
@@ -1,111 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Mail\UnacceptedAssetReminderMail;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\CheckoutAssetNotification;
use App\Notifications\CurrentInventory;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class SendAcceptanceReminder extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'snipeit:acceptance-reminder';
/**
* The console command description.
*
* @var string
*/
protected $description = 'This will resend users with unaccepted assets a reminder to accept or decline them.';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$pending = CheckoutAcceptance::pending()->where('checkoutable_type', 'App\Models\Asset')
->whereHas('checkoutable', function($query) {
$query->where('accepted_at', null)
->where('declined_at', null);
})
->with(['assignedTo', 'checkoutable.assignedTo', 'checkoutable.model', 'checkoutable.adminuser'])
->get();
$count = 0;
$unacceptedAssetGroups = $pending
->filter(function($acceptance) {
return $acceptance->checkoutable_type == 'App\Models\Asset';
})
->map(function($acceptance) {
return ['assetItem' => $acceptance->checkoutable, 'acceptance' => $acceptance];
})
->groupBy(function($item) {
return $item['acceptance']->assignedTo ? $item['acceptance']->assignedTo->id : '';
});
$no_email_list= [];
foreach($unacceptedAssetGroups as $unacceptedAssetGroup) {
// The [0] is weird, but it allows for the item_count to work and grabs the appropriate info for each user.
// Collapsing and flattening the collection doesn't work above.
$acceptance = $unacceptedAssetGroup[0]['acceptance'];
$locale = $acceptance->assignedTo?->locale;
$email = $acceptance->assignedTo?->email;
if(!$email){
$no_email_list[] = [
'id' => $acceptance->assignedTo?->id,
'name' => $acceptance->assignedTo?->display_name,
];
} else {
$count++;
}
$item_count = $unacceptedAssetGroup->count();
if ($locale && $email) {
Mail::to($email)->send((new UnacceptedAssetReminderMail($acceptance, $item_count))->locale($locale));
} elseif ($email) {
Mail::to($email)->send((new UnacceptedAssetReminderMail($acceptance, $item_count)));
}
}
$this->info($count.' users notified.');
$headers = ['ID', 'Name'];
$rows = [];
foreach ($no_email_list as $user) {
$rows[] = [$user['id'], $user['name']];
}
if (!empty($rows)) {
$this->info("The following users do not have an email address:");
$this->table($headers, $rows);
}
return 0;
}
}
+14 -15
View File
@@ -2,13 +2,13 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Mail\ExpiringAssetsMail;
use App\Mail\ExpiringLicenseMail;
use App\Models\Asset; use App\Models\Asset;
use App\Models\License; use App\Models\License;
use App\Models\Recipients\AlertRecipient;
use App\Models\Setting; use App\Models\Setting;
use App\Notifications\ExpiringAssetsNotification;
use App\Notifications\ExpiringLicenseNotification;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class SendExpirationAlerts extends Command class SendExpirationAlerts extends Command
{ {
@@ -42,28 +42,27 @@ class SendExpirationAlerts extends Command
public function handle() public function handle()
{ {
$settings = Setting::getSettings(); $settings = Setting::getSettings();
$alert_interval = $settings->alert_interval; $threshold = $settings->alert_interval;
if (($settings->alert_email != '') && ($settings->alerts_enabled == 1)) { if (($settings->alert_email != '') && ($settings->alerts_enabled == 1)) {
// Send a rollup to the admin, if settings dictate // Send a rollup to the admin, if settings dictate
$recipients = collect(explode(',', $settings->alert_email)) $recipients = collect(explode(',', $settings->alert_email))->map(function ($item, $key) {
->map(fn($item) => trim($item)) // Trim each email return new AlertRecipient($item);
->filter(fn($item) => !empty($item)) });
->all();
// Expiring Assets
$assets = Asset::getExpiringWarrantee($alert_interval);
// Expiring Assets
$assets = Asset::getExpiringWarrantee($threshold);
if ($assets->count() > 0) { if ($assets->count() > 0) {
$this->info(trans_choice('mail.assets_warrantee_alert', $assets->count(), ['count' => $assets->count(), 'threshold' => $alert_interval])); $this->info(trans_choice('mail.assets_warrantee_alert', $assets->count(), ['count' => $assets->count(), 'threshold' => $threshold]));
Mail::to($recipients)->send(new ExpiringAssetsMail($assets, $alert_interval)); \Notification::send($recipients, new ExpiringAssetsNotification($assets, $threshold));
} }
// Expiring licenses // Expiring licenses
$licenses = License::getExpiringLicenses($alert_interval); $licenses = License::getExpiringLicenses($threshold);
if ($licenses->count() > 0) { if ($licenses->count() > 0) {
$this->info(trans_choice('mail.license_expiring_alert', $licenses->count(), ['count' => $licenses->count(), 'threshold' => $alert_interval])); $this->info(trans_choice('mail.license_expiring_alert', $licenses->count(), ['count' => $licenses->count(), 'threshold' => $threshold]));
Mail::to($recipients)->send(new ExpiringLicenseMail($licenses, $alert_interval)); \Notification::send($recipients, new ExpiringLicenseNotification($licenses, $threshold));
} }
} else { } else {
if ($settings->alert_email == '') { if ($settings->alert_email == '') {
@@ -2,12 +2,13 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Mail\SendUpcomingAuditMail;
use App\Models\Asset; use App\Models\Asset;
use App\Models\Recipients\AlertRecipient;
use App\Models\Setting; use App\Models\Setting;
use App\Notifications\SendUpcomingAuditNotification;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class SendUpcomingAuditReport extends Command class SendUpcomingAuditReport extends Command
{ {
@@ -47,20 +48,19 @@ class SendUpcomingAuditReport extends Command
$today = Carbon::now(); $today = Carbon::now();
$interval_date = $today->copy()->addDays($interval); $interval_date = $today->copy()->addDays($interval);
$assets = Asset::whereNull('deleted_at')->dueOrOverdueForAudit($settings)->orderBy('assets.next_audit_date', 'desc')->get(); $assets = Asset::whereNull('deleted_at')->DueOrOverdueForAudit($settings)->orderBy('assets.next_audit_date', 'desc')->get();
$this->info($assets->count() . ' assets must be audited in on or before ' . $interval_date . ' is deadline'); $this->info($assets->count().' assets must be audited in on or before '.$interval_date.' is deadline');
if ((count($assets) !== 0) && ($assets->count() > 0) && ($settings->alert_email != '')) { if (($assets) && ($assets->count() > 0) && ($settings->alert_email != '')) {
// Send a rollup to the admin, if settings dictate // Send a rollup to the admin, if settings dictate
$recipients = collect(explode(',', $settings->alert_email)) $recipients = collect(explode(',', $settings->alert_email))->map(function ($item) {
->map(fn($item) => trim($item)) return new AlertRecipient($item);
->filter(fn($item) => !empty($item)) });
->all();
$this->info('Sending Admin SendUpcomingAuditNotification to: '.$settings->alert_email);
\Notification::send($recipients, new SendUpcomingAuditNotification($assets, $settings->audit_warning_days));
$this->info('Sending Admin SendUpcomingAuditNotification to: ' . $settings->alert_email);
Mail::to($recipients)->send(new SendUpcomingAuditMail($assets, $settings->audit_warning_days));
} }
} }
@@ -1,51 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Helpers\Helper;
use Illuminate\Console\Command;
class TestLocationsFMCS extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'snipeit:test-locations-fmcs {--location_id=}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Test for company ID inconsistencies if FullMultipleCompanySupport with scoped locations will be used.';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('This script checks for company ID inconsistencies if Full Multiple Company Support with scoped locations will be used.');
$this->info('This could take a few moments if have a very large dataset.');
$this->newLine();
// if parameter location_id is set, only test this location
$location_id = null;
if ($this->option('location_id')) {
$location_id = $this->option('location_id');
}
$mismatched = Helper::test_locations_fmcs(true, $location_id);
$this->warn(trans_choice('admin/settings/message.location_scoping.mismatch', count($mismatched)));
$this->newLine();
$this->info('Edit your locations to associate them with the correct company.');
$header = ['Type', 'ID', 'Name', 'Checkout Type', 'Company ID', 'Item Company', 'Item Location', 'Location Company', 'Location Company ID'];
sort($mismatched);
$this->table($header, $mismatched);
}
}
+4 -7
View File
@@ -5,7 +5,6 @@ namespace App\Console;
use App\Console\Commands\ImportLocations; use App\Console\Commands\ImportLocations;
use App\Console\Commands\ReEncodeCustomFieldNames; use App\Console\Commands\ReEncodeCustomFieldNames;
use App\Console\Commands\RestoreDeletedUsers; use App\Console\Commands\RestoreDeletedUsers;
use App\Models\Setting;
use Illuminate\Console\Scheduling\Schedule; use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel; use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
@@ -19,14 +18,12 @@ class Kernel extends ConsoleKernel
*/ */
protected function schedule(Schedule $schedule) protected function schedule(Schedule $schedule)
{ {
if(Setting::getSettings()?->alerts_enabled === 1) { $schedule->command('snipeit:inventory-alerts')->daily();
$schedule->command('snipeit:inventory-alerts')->daily(); $schedule->command('snipeit:expiring-alerts')->daily();
$schedule->command('snipeit:expiring-alerts')->daily(); $schedule->command('snipeit:expected-checkin')->daily();
$schedule->command('snipeit:expected-checkin')->daily();
$schedule->command('snipeit:upcoming-audits')->daily();
}
$schedule->command('snipeit:backup')->weekly(); $schedule->command('snipeit:backup')->weekly();
$schedule->command('backup:clean')->daily(); $schedule->command('backup:clean')->daily();
$schedule->command('snipeit:upcoming-audits')->daily();
$schedule->command('auth:clear-resets')->everyFifteenMinutes(); $schedule->command('auth:clear-resets')->everyFifteenMinutes();
$schedule->command('saml:clear_expired_nonces')->weekly(); $schedule->command('saml:clear_expired_nonces')->weekly();
} }
+1 -1
View File
@@ -28,7 +28,7 @@ class CheckoutableCheckedIn
$this->checkedOutTo = $checkedOutTo; $this->checkedOutTo = $checkedOutTo;
$this->checkedInBy = $checkedInBy; $this->checkedInBy = $checkedInBy;
$this->note = $note; $this->note = $note;
$this->action_date = $action_date ?? date('Y-m-d H:i:s'); $this->action_date = $action_date ?? date('Y-m-d');
$this->originalValues = $originalValues; $this->originalValues = $originalValues;
} }
} }
-9
View File
@@ -1,9 +0,0 @@
<?php
namespace App\Exceptions;
use Exception;
class AssetNotRequestable extends Exception
{
}
+4 -51
View File
@@ -11,7 +11,6 @@ use Illuminate\Support\Facades\Log;
use Throwable; use Throwable;
use JsonException; use JsonException;
use Carbon\Exceptions\InvalidFormatException; use Carbon\Exceptions\InvalidFormatException;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
class Handler extends ExceptionHandler class Handler extends ExceptionHandler
{ {
@@ -108,66 +107,21 @@ class Handler extends ExceptionHandler
$statusCode = $e->getStatusCode(); $statusCode = $e->getStatusCode();
// API throttle requests are handled in the RouteServiceProvider configureRateLimiting() method, so we don't need to handle them here
switch ($e->getStatusCode()) { switch ($e->getStatusCode()) {
case '404': case '404':
return response()->json(Helper::formatStandardApiResponse('error', null, $statusCode . ' endpoint not found'), 404); return response()->json(Helper::formatStandardApiResponse('error', null, $statusCode . ' endpoint not found'), 404);
case '429':
return response()->json(Helper::formatStandardApiResponse('error', null, 'Too many requests'), 429);
case '405': case '405':
return response()->json(Helper::formatStandardApiResponse('error', null, 'Method not allowed'), 405); return response()->json(Helper::formatStandardApiResponse('error', null, 'Method not allowed'), 405);
default: default:
return response()->json(Helper::formatStandardApiResponse('error', null, $statusCode), $statusCode); return response()->json(Helper::formatStandardApiResponse('error', null, $statusCode), $statusCode);
} }
} }
// This handles API validation exceptions that happen at the Form Request level, so they
// never even get to the controller where we normally nicely format JSON responses
if ($e instanceof ValidationException) {
$response = $this->invalidJson($request, $e);
return response()->json(Helper::formatStandardApiResponse('error', null, $e->errors()), 200);
}
} }
// This is traaaaash but it handles models that are not found while using route model binding :(
// The only alternative is to set that at *each* route, which is crazypants
if ($e instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) {
$ids = method_exists($e, 'getIds') ? $e->getIds() : [];
if (in_array('bulkedit', $ids, true)) {
$error_array = session()->get('bulk_asset_errors');
return redirect()
->route('hardware.index')
->withErrors($error_array, 'bulk_asset_errors')
->withInput();
}
// This gets the MVC model name from the exception and formats in a way that's less fugly
$model_name = trim(strtolower(implode(" ", preg_split('/(?=[A-Z])/', last(explode('\\', $e->getModel()))))));
$route = str_plural(strtolower(last(explode('\\', $e->getModel())))).'.index';
// Sigh.
if ($route == 'assets.index') {
$route = 'hardware.index';
} elseif ($route == 'reporttemplates.index') {
$route = 'reports/custom';
} elseif ($route == 'assetmodels.index') {
$route = 'models.index';
} elseif ($route == 'predefinedkits.index') {
$route = 'kits.index';
} elseif ($route == 'assetmaintenances.index') {
$route = 'maintenances.index';
} elseif ($route === 'licenseseats.index') {
$route = 'licenses.index';
} elseif (($route === 'customfieldsets.index') || ($route === 'customfields.index')) {
$route = 'fields.index';
}
return redirect()
->route($route)
->withError(trans('general.generic_model_not_found', ['model' => $model_name]));
}
if ($this->isHttpException($e) && (isset($statusCode)) && ($statusCode == '404' )) { if ($this->isHttpException($e) && (isset($statusCode)) && ($statusCode == '404' )) {
@@ -220,9 +174,8 @@ class Handler extends ExceptionHandler
*/ */
public function register() public function register()
{ {
$this->reportable(function (Throwable $e) { $this->reportable(function (Throwable $e) {
// //
}); });
} }
} }
@@ -1,10 +0,0 @@
<?php
namespace App\Exceptions;
use Exception;
class UserDoestExistException extends Exception
{
}
+80 -296
View File
@@ -12,13 +12,10 @@ use App\Models\Depreciation;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Statuslabel; use App\Models\Statuslabel;
use App\Models\License; use App\Models\License;
use App\Models\Location;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Contracts\Encryption\DecryptException;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Intervention\Image\ImageManagerStatic as Image; use Intervention\Image\ImageManagerStatic as Image;
use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Session;
@@ -65,9 +62,8 @@ class Helper
'mn' => 'mn-MN', // Mongolian 'mn' => 'mn-MN', // Mongolian
'ms' => 'ms-MY', // Malay 'ms' => 'ms-MY', // Malay
'nl' => 'nl-NL', // Dutch 'nl' => 'nl-NL', // Dutch
'no' => 'nb-NO', // Norwegian Bokmål 'no' => 'no-NO', // Norwegian
'pl' => 'pl-PL', // Polish 'pl' => 'pl-PL', // Polish
'pt' => 'pt-PT', // Portuguese
'ro' => 'ro-RO', // Romanian 'ro' => 'ro-RO', // Romanian
'ru' => 'ru-RU', // Russian 'ru' => 'ru-RU', // Russian
'sk' => 'sk-SK', // Slovak 'sk' => 'sk-SK', // Slovak
@@ -82,13 +78,6 @@ class Helper
'zu' => 'zu-ZA', // Zulu 'zu' => 'zu-ZA', // Zulu
]; ];
public static function hasRtl($value) {
$rtlChar = '/[\x{0590}-\x{083F}]|[\x{08A0}-\x{08FF}]|[\x{FB1D}-\x{FDFF}]|[\x{FE70}-\x{FEFF}]/u';
return preg_match($rtlChar, $value) != 0;
}
/** /**
* Simple helper to invoke the markdown parser * Simple helper to invoke the markdown parser
* *
@@ -718,28 +707,6 @@ class Helper
return $randomString; return $randomString;
} }
/**
* A method to be used to handle deprecations notifications, currently handling MS Teams. more can be added when needed.
*
*
* @author [Godfrey Martinez]
* @since [v7.0.14]
* @return array
*/
public static function deprecationCheck() : array {
// The check and message that the user is still using the deprecated version
$deprecations = [
'ms_teams_deprecated' => array(
'check' => !Str::contains(Setting::getSettings()->webhook_endpoint, 'workflows') && (Setting::getSettings()->webhook_selected === 'microsoft'),
'message' => 'The Microsoft Teams webhook URL being used will be deprecated Dec 31st, 2025. <a class="btn btn-primary" href="' . route('settings.slack.index') . '">Change webhook endpoint</a>'),
];
// if item of concern is being used and its being used with the deprecated values return the notification array.
if(Setting::getSettings()->webhook_selected === 'microsoft' && $deprecations['ms_teams_deprecated']['check']) {
return $deprecations;
}
return [];
}
/** /**
* This nasty little method gets the low inventory info for the * This nasty little method gets the low inventory info for the
@@ -753,7 +720,7 @@ class Helper
{ {
$alert_threshold = \App\Models\Setting::getSettings()->alert_threshold; $alert_threshold = \App\Models\Setting::getSettings()->alert_threshold;
$consumables = Consumable::withCount('consumableAssignments as consumable_assignments_count')->whereNotNull('min_amt')->get(); $consumables = Consumable::withCount('consumableAssignments as consumable_assignments_count')->whereNotNull('min_amt')->get();
$accessories = Accessory::withCount('checkouts as checkouts_count')->whereNotNull('min_amt')->get(); $accessories = Accessory::withCount('users as users_count')->whereNotNull('min_amt')->get();
$components = Component::whereNotNull('min_amt')->get(); $components = Component::whereNotNull('min_amt')->get();
$asset_models = AssetModel::where('min_amt', '>', 0)->get(); $asset_models = AssetModel::where('min_amt', '>', 0)->get();
$licenses = License::where('min_amt', '>', 0)->get(); $licenses = License::where('min_amt', '>', 0)->get();
@@ -781,7 +748,7 @@ class Helper
} }
foreach ($accessories as $accessory) { foreach ($accessories as $accessory) {
$avail = $accessory->qty - $accessory->checkouts_count; $avail = $accessory->qty - $accessory->users_count;
if ($avail < ($accessory->min_amt) + $alert_threshold) { if ($avail < ($accessory->min_amt) + $alert_threshold) {
if ($accessory->qty > 0) { if ($accessory->qty > 0) {
$percent = number_format((($avail / $accessory->qty) * 100), 0); $percent = number_format((($avail / $accessory->qty) * 100), 0);
@@ -877,49 +844,7 @@ class Helper
$filetype = @finfo_file($finfo, $file); $filetype = @finfo_file($finfo, $file);
finfo_close($finfo); finfo_close($finfo);
if (($filetype == 'image/jpeg') || ($filetype == 'image/jpg') || ($filetype == 'image/png') || ($filetype == 'image/bmp') || ($filetype == 'image/gif') || ($filetype == 'image/avif') || ($filetype == 'image/webp')) { if (($filetype == 'image/jpeg') || ($filetype == 'image/jpg') || ($filetype == 'image/png') || ($filetype == 'image/bmp') || ($filetype == 'image/gif') || ($filetype == 'image/avif')) {
return $filetype;
}
return false;
}
/**
* Check if the file is a video, so we can show a preview
*
* @param File $file
* @return string | Boolean
* @author [B. Wetherington] [<bwetherington@grokability.com>]
* @since [v8.1.18]
*/
public static function checkUploadIsVideo($file)
{
$finfo = @finfo_open(FILEINFO_MIME_TYPE); // return mime type ala mimetype extension
$filetype = @finfo_file($finfo, $file);
finfo_close($finfo);
if (($filetype == 'video/mp4') || ($filetype == 'video/quicktime') || ($filetype == 'video/mpeg') || ($filetype == 'video/ogg') || ($filetype == 'video/webm') || ($filetype == 'video/x-msvide')) {
return $filetype;
}
return false;
}
/**
* Check if the file is audio, so we can show a preview
*
* @param File $file
* @return string | Boolean
* @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v3.0]
*/
public static function checkUploadIsAudio($file)
{
$finfo = @finfo_open(FILEINFO_MIME_TYPE); // return mime type ala mimetype extension
$filetype = @finfo_file($finfo, $file);
finfo_close($finfo);
if (($filetype == 'audio/mpeg') || ($filetype == 'audio/ogg')) {
return $filetype; return $filetype;
} }
@@ -946,12 +871,6 @@ class Helper
public static function selectedPermissionsArray($permissions, $selected_arr = []) public static function selectedPermissionsArray($permissions, $selected_arr = [])
{ {
$permissions_arr = []; $permissions_arr = [];
if (is_array($permissions)) {
$permissions = json_encode($permissions);
}
// Set default to empty JSON if the value is null
$permissions = json_decode($permissions ?? '{}', JSON_OBJECT_AS_ARRAY);
foreach ($permissions as $permission) { foreach ($permissions as $permission) {
for ($x = 0; $x < count($permission); $x++) { for ($x = 0; $x < count($permission); $x++) {
@@ -962,13 +881,13 @@ class Helper
if (is_array($selected_arr)) { if (is_array($selected_arr)) {
if (array_key_exists($permission_name, $selected_arr)) { if (array_key_exists($permission_name, $selected_arr)) {
$permissions_arr[$permission_name] = (int) $selected_arr[$permission_name]; $permissions_arr[$permission_name] = $selected_arr[$permission_name];
} else { } else {
$permissions_arr[$permission_name] = 0; $permissions_arr[$permission_name] = '0';
} }
} else { } else {
$permissions_arr[$permission_name] = 0; $permissions_arr[$permission_name] = '0';
} }
} }
} }
@@ -994,22 +913,13 @@ class Helper
$rules = $class::rules(); $rules = $class::rules();
foreach ($rules as $rule_name => $rule) { foreach ($rules as $rule_name => $rule) {
if ($rule_name == $field) { if ($rule_name == $field) {
if (is_array($rule)) { if (strpos($rule, 'required') === false) {
if (in_array('required', $rule)) { return false;
return true;
} else {
return false;
}
} else { } else {
if (strpos($rule, 'required') === false) { return true;
return false; }
} else {
return true;
}
}
} }
} }
return false;
} }
/** /**
@@ -1203,43 +1113,22 @@ class Helper
'png' => 'far fa-image', 'png' => 'far fa-image',
'webp' => 'far fa-image', 'webp' => 'far fa-image',
'avif' => 'far fa-image', 'avif' => 'far fa-image',
'svg' => 'fas fa-vector-square',
// word // word
'doc' => 'far fa-file-word', 'doc' => 'far fa-file-word',
'docx' => 'far fa-file-word', 'docx' => 'far fa-file-word',
// Excel // Excel
'xls' => 'far fa-file-excel', 'xls' => 'far fa-file-excel',
'xlsx' => 'far fa-file-excel', 'xlsx' => 'far fa-file-excel',
'ods' => 'far fa-file-excel',
// Presentation
'ppt' => 'far fa-file-powerpoint',
'odp' => 'far fa-file-powerpoint',
// archive // archive
'zip' => 'fas fa-file-archive', 'zip' => 'fas fa-file-archive',
'rar' => 'fas fa-file-archive', 'rar' => 'fas fa-file-archive',
//Text //Text
'odt' => 'far fa-file-alt',
'txt' => 'far fa-file-alt', 'txt' => 'far fa-file-alt',
'rtf' => 'far fa-file-alt', 'rtf' => 'far fa-file-alt',
'xml' => 'fas fa-code', 'xml' => 'far fa-file-alt',
// Misc // Misc
'pdf' => 'far fa-file-pdf', 'pdf' => 'far fa-file-pdf',
'lic' => 'far fa-save', 'lic' => 'far fa-save',
// video
'mov' => 'fa-solid fa-video',
'mp4' => 'fa-solid fa-video',
// audio
'ogg' => 'fa-solid fa-file-audio',
'mp3' => 'fa-solid fa-file-audio',
'wav' => 'fa-solid fa-file-audio',
]; ];
if ($extension && array_key_exists($extension, $allowedExtensionMap)) { if ($extension && array_key_exists($extension, $allowedExtensionMap)) {
@@ -1249,7 +1138,41 @@ class Helper
return 'far fa-file'; return 'far fa-file';
} }
public static function show_file_inline($filename)
{
$extension = substr(strrchr($filename, '.'), 1);
if ($extension) {
switch ($extension) {
case 'jpg':
case 'jpeg':
case 'gif':
case 'png':
case 'webp':
case 'avif':
return true;
break;
default:
return false;
}
}
return false;
}
/**
* Generate a random encrypted password.
*
* @author Wes Hulette <jwhulette@gmail.com>
*
* @since 5.0.0
*
* @return string
*/
public static function generateEncyrptedPassword(): string
{
return bcrypt(self::generateUnencryptedPassword());
}
/** /**
* Get a random unencrypted password. * Get a random unencrypted password.
@@ -1383,24 +1306,25 @@ class Helper
switch ($item) { switch ($item) {
case 'asset': case 'asset':
return 'fas fa-barcode'; return 'fas fa-barcode';
break;
case 'accessory': case 'accessory':
return 'fas fa-keyboard'; return 'fas fa-keyboard';
break;
case 'component': case 'component':
return 'fas fa-hdd'; return 'fas fa-hdd';
break;
case 'consumable': case 'consumable':
return 'fas fa-tint'; return 'fas fa-tint';
break;
case 'license': case 'license':
return 'far fa-save'; return 'far fa-save';
break;
case 'location': case 'location':
return 'fas fa-map-marker-alt'; return 'fas fa-map-marker-alt';
break;
case 'user': case 'user':
return 'fas fa-user'; return 'fas fa-user';
case 'supplier': break;
return 'fa-solid fa-store';
case 'manufacturer':
return 'fa-solid fa-building';
case 'category':
return 'fa-solid fa-table-columns';
} }
} }
@@ -1516,6 +1440,7 @@ class Helper
foreach (self::$language_map as $legacy => $new) { foreach (self::$language_map as $legacy => $new) {
if ($language_code == $legacy) { if ($language_code == $legacy) {
Log::debug('Current language is '.$legacy.', using '.$new.' instead');
return $new; return $new;
} }
} }
@@ -1526,7 +1451,6 @@ class Helper
public static function mapBackToLegacyLocale($new_locale = null) public static function mapBackToLegacyLocale($new_locale = null)
{ {
if (strlen($new_locale) <= 4) { if (strlen($new_locale) <= 4) {
return $new_locale; //"new locale" apparently wasn't quite so new return $new_locale; //"new locale" apparently wasn't quite so new
} }
@@ -1534,184 +1458,44 @@ class Helper
// This does a *reverse* search against our new language map array - given the value, find the *key* for it // This does a *reverse* search against our new language map array - given the value, find the *key* for it
$legacy_locale = array_search($new_locale, self::$language_map); $legacy_locale = array_search($new_locale, self::$language_map);
if ($legacy_locale !== false) { if($legacy_locale !== false) {
return $legacy_locale; return $legacy_locale;
} }
return $new_locale; // better that you have some weird locale that doesn't fit into our mappings anywhere than 'void' return $new_locale; // better that you have some weird locale that doesn't fit into our mappings anywhere than 'void'
} }
public static function determineLanguageDirection() {
return in_array(app()->getLocale(),
[
'ar-SA',
'fa-IR',
'he-IL'
]) ? 'rtl' : 'ltr';
}
static public function getRedirectOption($request, $id, $table, $asset_id = null)
static public function getRedirectOption($request, $id, $table, $item_id = null) : RedirectResponse
{ {
$redirect_option = Session::get('redirect_option') ?? $request->redirect_option; $redirect_option = Session::get('redirect_option');
$checkout_to_type = Session::get('checkout_to_type') ?? null; $checkout_to_type = Session::get('checkout_to_type');
$checkedInFrom = Session::get('checkedInFrom');
$other_redirect = Session::get('other_redirect');
$backUrl = Session::pull('back_url', route('home'));
// return to previous page //return to index
if ($redirect_option === 'back') { if ($redirect_option == '0') {
return redirect()->to($backUrl); switch ($table) {
case "Assets":
return redirect()->route('hardware.index')->with('success', trans('admin/hardware/message.checkout.success'));
}
} }
//return to thing being assigned
// return to index if ($redirect_option == '1') {
if ($redirect_option == 'index') { switch ($table) {
return match ($table) { case "Assets":
'Assets' => redirect()->route('hardware.index'), return redirect()->route('hardware.show', $id ? $id : $asset_id)->with('success', trans('admin/hardware/message.checkout.success'));
'Users' => redirect()->route('users.index'), }
'Licenses' => redirect()->route('licenses.index'),
'Accessories' => redirect()->route('accessories.index'),
'Components' => redirect()->route('components.index'),
'Consumables' => redirect()->route('consumables.index'),
};
} }
//return to thing being assigned to
// return to thing being assigned if ($redirect_option == '2') {
if ($redirect_option == 'item') { switch ($checkout_to_type) {
return match ($table) { case 'user':
'Assets' => redirect()->route('hardware.show', $id ?? $item_id), return redirect()->route('users.show', $request->assigned_user)->with('success', trans('admin/hardware/message.checkout.success'));
'Users' => redirect()->route('users.show', $id ?? $item_id), case 'location':
'Licenses' => redirect()->route('licenses.show', $id ?? $item_id), return redirect()->route('locations.show', $request->assigned_location)->with('success', trans('admin/hardware/message.checkout.success'));
'Accessories' => redirect()->route('accessories.show', $id ?? $item_id), case 'asset':
'Components' => redirect()->route('components.show', $id ?? $item_id), return redirect()->route('hardware.show', $request->assigned_asset)->with('success', trans('admin/hardware/message.checkout.success'));
'Consumables' => redirect()->route('consumables.show', $id ?? $item_id), }
};
} }
// return to assignment target
if ($redirect_option == 'target') {
return match ($checkout_to_type) {
'user' => redirect()->route('users.show', $request->assigned_user ?? $checkedInFrom),
'location' => redirect()->route('locations.show', $request->assigned_location ?? $checkedInFrom),
'asset' => redirect()->route('hardware.show', $request->assigned_asset ?? $checkedInFrom),
};
}
// return to somewhere else
if ($redirect_option == 'other_redirect') {
return match ($other_redirect) {
'audit' => redirect()->route('assets.audit.due'),
'model' => redirect()->route('models.show', $request->model_id),
};
}
return redirect()->back()->with('error', trans('admin/hardware/message.checkout.error')); return redirect()->back()->with('error', trans('admin/hardware/message.checkout.error'));
} }
/**
* Check for inconsistencies before activating scoped locations with FullMultipleCompanySupport
* If there are locations with different companies than related objects unforseen problems could arise
*
* @author T. Regnery <tobias.regnery@gmail.com>
* @since 7.0
*
* @param $artisan when false, bail out on first inconsistent entry
* @param $location_id when set, only test this specific location
* @param $new_company_id in case of updating a location, this is the newly requested company_id
* @return string []
*/
static public function test_locations_fmcs($artisan, $location_id = null, $new_company_id = null) {
$mismatched = [];
if ($location_id) {
$location = Location::find($location_id);
if ($location) {
$locations = collect([])->push(Location::find($location_id));
}
} else {
$locations = Location::all();
}
// Bail out early if there are no locations
if ($locations->count() == 0) {
return [];
}
foreach($locations as $location) {
// in case of an update of a single location, use the newly requested company_id
if ($new_company_id) {
$location_company = $new_company_id;
} else {
$location_company = $location->company_id;
}
// Depending on the relationship, we must use different operations to retrieve the objects
$keywords_relation = [
'many' => [
'accessories',
'assets',
'assignedAccessories',
'assignedAssets',
'components',
'consumables',
'rtd_assets',
'users',
],
'one' => [
'manager',
'parent',
]];
// In case of a single location, the children must be checked as well, because we don't walk every location
if ($location_id) {
$keywords_relation['many'][] = 'children';
}
foreach ($keywords_relation as $relation => $keywords) {
foreach($keywords as $keyword) {
if ($relation == 'many') {
$items = $location->{$keyword}->all();
} else {
$items = collect([])->push($location->$keyword);
}
$count = 0;
foreach ($items as $item) {
if ($item && $item->company_id != $location_company) {
$mismatched[] = [
class_basename(get_class($item)),
$item->id,
$item->name ?? $item->asset_tag ?? $item->serial ?? $item->username,
$item->assigned_type ? str_replace('App\\Models\\', '', $item->assigned_type) : null,
$item->company_id ?? null,
$item->company->name ?? null,
// $item->defaultLoc->id ?? null,
// $item->defaultLoc->name ?? null,
// $item->defaultLoc->company->id ?? null,
// $item->defaultLoc->company->name ?? null,
$item->location->name ?? null,
$item->location->company->name ?? null,
$location_company ?? null,
];
$count++;
// Bail early if this is not being run via artisan
if ((!$artisan) && ($count > 0)) {
return $mismatched;
}
}
}
}
}
}
return $mismatched;
}
} }
-200
View File
@@ -1,200 +0,0 @@
<?php
namespace App\Helpers;
class IconHelper
{
public static function icon($type) {
switch ($type) {
case 'checkout':
return 'fa-solid fa-rotate-left';
case 'checkin':
return 'fa-solid fa-rotate-right';
case 'edit':
return 'fas fa-pencil-alt';
case 'clone':
return 'far fa-clone';
case 'delete':
case 'upload deleted':
return 'fas fa-trash';
case 'create':
return 'fa-solid fa-plus';
case 'audit':
return 'fa-solid fa-clipboard-check';
case '2fa reset':
return 'fa-solid fa-mobile-screen';
case 'new-user':
return 'fa-solid fa-user-plus';
case 'merged-user':
return 'fa-solid fa-people-arrows';
case 'delete-user':
return 'fa-solid fa-user-minus';
case 'update-user':
return 'fa-solid fa-user-pen';
case 'user':
return 'fa-solid fa-user';
case 'users':
return 'fas fa-users';
case 'restore':
return 'fa-solid fa-trash-arrow-up';
case 'external-link':
return 'fa fa-external-link';
case 'email':
return 'fa-regular fa-envelope';
case 'phone':
return 'fa-solid fa-phone';
case 'mobile':
return 'fas fa-mobile-screen-button';
case 'long-arrow-right':
return 'fas fa-long-arrow-alt-right';
case 'download':
return 'fas fa-download';
case 'checkmark':
return 'fas fa-check icon-white';
case 'x':
return 'fas fa-times';
case 'logout':
return 'fa fa-sign-out';
case 'admin-settings':
return 'fas fa-cogs';
case 'settings':
return 'fas fa-cog';
case 'angle-left':
return 'fas fa-angle-left';
case 'angle-right':
return 'fas fa-angle-right';
case 'warning':
return 'fas fa-exclamation-triangle';
case 'kits':
return 'fas fa-object-group';
case 'assets':
case 'asset':
return 'fas fa-barcode';
case 'accessories':
case 'accessory':
return 'far fa-keyboard';
case 'components':
case 'component':
return 'far fa-hdd';
case 'consumables':
case 'consumable':
return 'fas fa-tint';
case 'licenses':
case 'license':
return 'far fa-save';
case 'requestable':
return 'fas fa-laptop';
case 'reports':
return 'fas fa-chart-bar';
case 'heart':
return 'fas fa-heart';
case 'circle':
return 'fa-regular fa-circle';
case 'circle-solid':
return 'fa-solid fa-circle';
case 'due':
return 'fas fa-history';
case 'import':
return 'fas fa-cloud-upload-alt';
case 'search':
return 'fas fa-search';
case 'alerts':
return 'far fa-flag';
case 'password':
return 'fa-solid fa-key';
case 'api-key':
return 'fa-solid fa-user-secret';
case 'nav-toggle':
return 'fas fa-bars';
case 'dashboard':
return 'fas fa-tachometer-alt';
case 'info-circle':
return 'fas fa-info-circle';
case 'caret-right':
return 'fa fa-caret-right';
case 'caret-up':
return 'fa fa-caret-up';
case 'caret-down':
return 'fa fa-caret-down';
case 'arrow-circle-right':
return 'fa fa-arrow-circle-right';
case 'minus':
return 'fas fa-minus';
case 'spinner':
return 'fas fa-spinner fa-spin';
case 'copy-clipboard':
return 'fa-regular fa-clipboard';
case 'paperclip':
return 'fas fa-paperclip';
case 'files':
return 'fa-regular fa-file';
case 'more-info':
return 'far fa-life-ring';
case 'calendar':
return 'fas fa-calendar';
case 'plus':
return 'fas fa-plus';
case 'history':
return 'fas fa-history';
case 'more-files':
return 'fa-solid fa-laptop-file';
case 'maintenances':
return 'fas fa-wrench';
case 'seats':
return 'far fa-list-alt';
case 'globe-us':
return 'fas fa-globe-americas';
case 'locked':
return 'fas fa-lock';
case 'unlocked':
return 'fas fa-lock';
case 'locations':
return 'fas fa-map-marker-alt';
case 'location':
return 'fas fa-map-marker-alt';
case 'superadmin':
case 'admin':
return 'fas fa-crown';
case 'print':
return 'fa-solid fa-print';
case 'checkin-and-delete':
return 'fa-solid fa-user-xmark';
case 'branding':
return 'fas fa-copyright';
case 'general-settings':
return 'fa-solid fa-list-check';
case 'groups':
return 'fa-solid fa-user-group';
case 'bell':
return 'fa-solid fa-bell';
case 'hashtag':
return 'fa-solid fa-hashtag';
case 'asset-tags':
return 'fas fa-list-ol';
case 'labels':
return 'fas fa-tags';
case 'ldap':
return 'fas fa-sitemap';
case 'google':
return 'fa-brands fa-google';
case 'saml':
return 'fas fa-sign-in-alt';
case 'backups':
return 'fas fa-file-archive';
case 'logins':
return 'fas fa-crosshairs';
case 'oauth':
return 'fas fa-user-secret';
case 'employee_num' :
return 'fa-regular fa-id-card';
case 'department' :
return 'fa-solid fa-building-user';
case 'home' :
return 'fa-solid fa-house';
case 'note':
case 'notes':
return 'fas fa-sticky-note';
}
}
}
+8 -132
View File
@@ -3,147 +3,23 @@
namespace App\Helpers; namespace App\Helpers;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Response;
use Illuminate\Http\RedirectResponse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
class StorageHelper class StorageHelper
{ {
public static function downloader($filename, $disk = 'default') : BinaryFileResponse | RedirectResponse | StreamedResponse public static function downloader($filename, $disk = 'default')
{ {
if ($disk == 'default') { if ($disk == 'default') {
$disk = config('filesystems.default'); $disk = config('filesystems.default');
} }
switch (config("filesystems.disks.$disk.driver")) { switch (config("filesystems.disks.$disk.driver")) {
case 'local': case 'local':
return response()->download(Storage::disk($disk)->path($filename)); //works for PRIVATE or public?! return response()->download(Storage::disk($disk)->path($filename)); //works for PRIVATE or public?!
case 's3': case 's3':
return redirect()->away(Storage::disk($disk)->temporaryUrl($filename, now()->addMinutes(5))); //works for private or public, I guess? return redirect()->away(Storage::disk($disk)->temporaryUrl($filename, now()->addMinutes(5))); //works for private or public, I guess?
default: default:
return Storage::disk($disk)->download($filename); return Storage::disk($disk)->download($filename);
} }
} }
public static function getMediaType($file_with_path) {
// Get the file extension and determine the media type
if (Storage::exists($file_with_path)) {
$fileinfo = pathinfo($file_with_path);
$extension = strtolower($fileinfo['extension']);
switch ($extension) {
case 'avif':
case 'jpg':
case 'png':
case 'gif':
case 'svg':
case 'webp':
return 'image';
case 'pdf':
return 'pdf';
case 'mp3':
case 'wav':
case 'ogg':
return 'audio';
case 'mp4':
case 'webm':
case 'mov':
return 'video';
case 'doc':
case 'docx':
return 'document';
case 'txt':
return 'text';
case 'xls':
case 'xlsx':
case 'ods':
return 'spreadsheet';
default:
return $extension; // Default for unknown types
}
}
return null;
}
/**
* This determines the file types that should be allowed inline and checks their fileinfo extension
* to determine that they are safe to display inline.
*
* @author <A. Gianotto> [<snipe@snipe.net]>
* @since v7.0.14
* @param $file_with_path
* @return bool
*/
public static function allowSafeInline($file_with_path)
{
$allowed_inline = [
'avif',
'gif',
'gif',
'jpg',
'mov',
'mp3',
'mp4',
'ogg',
'pdf',
'png',
'svg',
'wav',
'webm',
'webp',
];
// The file exists and is allowed to be displayed inline
if (Storage::exists($file_with_path) && (in_array(pathinfo($file_with_path, PATHINFO_EXTENSION), $allowed_inline))) {
return true;
}
return false;
}
public static function getFiletype($file_with_path)
{
// The file exists and is allowed to be displayed inline
if (Storage::exists($file_with_path)) {
return pathinfo($file_with_path, PATHINFO_EXTENSION);
}
return null;
}
/**
* Decide whether to show the file inline or download it.
*/
public static function showOrDownloadFile($file, $filename)
{
$headers = [];
if (request('inline') == 'true') {
$headers = [
'Content-Disposition' => 'inline',
];
// This is NOT allowed as inline - force it to be displayed as text in the browser
if (self::allowSafeInline($file) != true) {
$headers = array_merge($headers, ['Content-Type' => 'text/plain']);
}
}
// Everything else seems okay, but the file doesn't exist on the server.
if (Storage::missing($file)) {
throw new FileNotFoundException();
}
return Storage::download($file, $filename, $headers);
}
} }
@@ -7,10 +7,10 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\ImageUploadRequest; use App\Http\Requests\ImageUploadRequest;
use App\Models\Accessory; use App\Models\Accessory;
use App\Models\Company; use App\Models\Company;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use \Illuminate\Contracts\View\View; use Redirect;
use \Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
/** This controller handles all actions related to Accessories for /** This controller handles all actions related to Accessories for
@@ -27,19 +27,24 @@ class AccessoriesController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @see AccessoriesController::getDatatable() method that generates the JSON response * @see AccessoriesController::getDatatable() method that generates the JSON response
* @since [v1.0] * @since [v1.0]
* @return View
* @throws \Illuminate\Auth\Access\AuthorizationException
*/ */
public function index() : View public function index()
{ {
$this->authorize('index', Accessory::class); $this->authorize('index', Accessory::class);
return view('accessories.index');
return view('accessories/index');
} }
/** /**
* Returns a view with a form to create a new Accessory. * Returns a view with a form to create a new Accessory.
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @return View
* @throws \Illuminate\Auth\Access\AuthorizationException
*/ */
public function create() : View public function create()
{ {
$this->authorize('create', Accessory::class); $this->authorize('create', Accessory::class);
$category_type = 'accessory'; $category_type = 'accessory';
@@ -53,8 +58,10 @@ class AccessoriesController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @param ImageUploadRequest $request * @param ImageUploadRequest $request
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/ */
public function store(ImageUploadRequest $request) : RedirectResponse public function store(ImageUploadRequest $request)
{ {
$this->authorize(Accessory::class); $this->authorize(Accessory::class);
@@ -73,34 +80,16 @@ class AccessoriesController extends Controller
$accessory->purchase_date = request('purchase_date'); $accessory->purchase_date = request('purchase_date');
$accessory->purchase_cost = request('purchase_cost'); $accessory->purchase_cost = request('purchase_cost');
$accessory->qty = request('qty'); $accessory->qty = request('qty');
$accessory->created_by = auth()->id(); $accessory->user_id = Auth::user()->id;
$accessory->supplier_id = request('supplier_id'); $accessory->supplier_id = request('supplier_id');
$accessory->notes = request('notes'); $accessory->notes = request('notes');
if ($request->has('use_cloned_image')) { $accessory = $request->handleImages($accessory);
$cloned_model_img = Accessory::select('image')->find($request->input('clone_image_from_id'));
if ($cloned_model_img) {
$new_image_name = 'clone-'.date('U').'-'.$cloned_model_img->image;
$new_image = 'accessories/'.$new_image_name;
Storage::disk('public')->copy('accessories/'.$cloned_model_img->image, $new_image);
$accessory->image = $new_image_name;
}
} else {
$accessory = $request->handleImages($accessory);
}
if($request->get('redirect_option') === 'back'){
session()->put(['redirect_option' => 'index']);
} else {
session()->put(['redirect_option' => $request->get('redirect_option')]);
}
// Was the accessory created? // Was the accessory created?
if ($accessory->save()) { if ($accessory->save()) {
// Redirect to the new accessory page // Redirect to the new accessory page
return Helper::getRedirectOption($request, $accessory->id, 'Accessories') return redirect()->route('accessories.index')->with('success', trans('admin/accessories/message.create.success'));
->with('success', trans('admin/accessories/message.create.success'));
} }
return redirect()->back()->withInput()->withErrors($accessory->getErrors()); return redirect()->back()->withInput()->withErrors($accessory->getErrors());
@@ -111,11 +100,20 @@ class AccessoriesController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @param int $accessoryId * @param int $accessoryId
* @return View
* @throws \Illuminate\Auth\Access\AuthorizationException
*/ */
public function edit(Accessory $accessory) : View | RedirectResponse public function edit($accessoryId = null)
{ {
$this->authorize('update', Accessory::class);
return view('accessories.edit')->with('item', $accessory)->with('category_type', 'accessory'); if ($item = Accessory::find($accessoryId)) {
$this->authorize($item);
return view('accessories/edit', compact('item'))->with('category_type', 'accessory');
}
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.does_not_exist'));
} }
/** /**
@@ -124,19 +122,26 @@ class AccessoriesController extends Controller
* @author [J. Vinsmoke] * @author [J. Vinsmoke]
* @param int $accessoryId * @param int $accessoryId
* @since [v6.0] * @since [v6.0]
* @return View
*/ */
public function getClone(Accessory $accessory) : View | RedirectResponse public function getClone($accessoryId = null)
{ {
$this->authorize('create', Accessory::class); $this->authorize('create', Accessory::class);
$cloned = clone $accessory;
$accessory_to_clone = $accessory; // Check if the asset exists
$cloned->id = null; if (is_null($accessory_to_clone = Accessory::find($accessoryId))) {
$cloned->deleted_at = ''; // Redirect to the asset management page
return redirect()->route('accessories.index')
->with('error', trans('admin/accessories/message.does_not_exist', ['id' => $accessoryId]));
}
$accessory = clone $accessory_to_clone;
$accessory->id = null;
$accessory->location_id = null;
return view('accessories/edit') return view('accessories/edit')
->with('cloned_model', $accessory_to_clone) ->with('item', $accessory);
->with('item', $cloned);
} }
@@ -146,15 +151,17 @@ class AccessoriesController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @param ImageUploadRequest $request * @param ImageUploadRequest $request
* @param int $accessoryId * @param int $accessoryId
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/ */
public function update(ImageUploadRequest $request, Accessory $accessory) : RedirectResponse public function update(ImageUploadRequest $request, $accessoryId = null)
{ {
if ($accessory = Accessory::withCount('checkouts as checkouts_count')->find($accessory->id)) { if ($accessory = Accessory::withCount('users as users_count')->find($accessoryId)) {
$this->authorize($accessory); $this->authorize($accessory);
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
"qty" => "required|numeric|min:$accessory->checkouts_count" "qty" => "required|numeric|min:$accessory->users_count"
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@@ -182,11 +189,9 @@ class AccessoriesController extends Controller
$accessory = $request->handleImages($accessory); $accessory = $request->handleImages($accessory);
session()->put(['redirect_option' => $request->get('redirect_option')]); // Was the accessory updated?
if ($accessory->save()) { if ($accessory->save()) {
return Helper::getRedirectOption($request, $accessory->id, 'Accessories') return redirect()->route('accessories.index')->with('success', trans('admin/accessories/message.update.success'));
->with('success', trans('admin/accessories/message.update.success'));
} }
} else { } else {
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.does_not_exist')); return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.does_not_exist'));
@@ -200,18 +205,20 @@ class AccessoriesController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @param int $accessoryId * @param int $accessoryId
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/ */
public function destroy($accessoryId) : RedirectResponse public function destroy($accessoryId)
{ {
if (is_null($accessory = Accessory::withCount('checkouts as checkouts_count')->find($accessoryId))) { if (is_null($accessory = Accessory::find($accessoryId))) {
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.not_found')); return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.not_found'));
} }
$this->authorize($accessory); $this->authorize($accessory);
if ($accessory->checkouts_count > 0) { if ($accessory->hasUsers() > 0) {
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/general.delete_disabled')); return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.assoc_users', ['count'=> $accessory->hasUsers()]));
} }
if ($accessory->image) { if ($accessory->image) {
@@ -236,14 +243,17 @@ class AccessoriesController extends Controller
* @param int $accessoryID * @param int $accessoryID
* @see AccessoriesController::getDataView() method that generates the JSON response * @see AccessoriesController::getDataView() method that generates the JSON response
* @since [v1.0] * @since [v1.0]
* @return View
* @throws \Illuminate\Auth\Access\AuthorizationException
*/ */
public function show(Accessory $accessory) : View | RedirectResponse public function show($accessoryID = null)
{ {
$accessory->loadCount('checkouts as checkouts_count'); $accessory = Accessory::withCount('users as users_count')->find($accessoryID);
$accessory->load(['adminuser' => fn($query) => $query->withTrashed()]);
$this->authorize('view', $accessory); $this->authorize('view', $accessory);
return view('accessories.view', compact('accessory')); if (isset($accessory->id)) {
return view('accessories/view', compact('accessory'));
}
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.does_not_exist', ['id' => $accessoryID]));
} }
} }
@@ -0,0 +1,159 @@
<?php
namespace App\Http\Controllers\Accessories;
use App\Helpers\StorageHelper;
use App\Http\Controllers\Controller;
use App\Http\Requests\UploadFileRequest;
use App\Models\Actionlog;
use App\Models\Accessory;
use Illuminate\Support\Facades\Response;
use Illuminate\Support\Facades\Storage;
use Symfony\Accessory\HttpFoundation\JsonResponse;
use Illuminate\Support\Facades\Log;
class AccessoriesFilesController extends Controller
{
/**
* Validates and stores files associated with a accessory.
*
* @param UploadFileRequest $request
* @param int $accessoryId
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*@author [A. Gianotto] [<snipe@snipe.net>]
* @since [v1.0]
* @todo Switch to using the AssetFileRequest form request validator.
*/
public function store(UploadFileRequest $request, $accessoryId = null)
{
if (config('app.lock_passwords')) {
return redirect()->route('accessories.show', ['accessory'=>$accessoryId])->with('error', trans('general.feature_disabled'));
}
$accessory = Accessory::find($accessoryId);
if (isset($accessory->id)) {
$this->authorize('accessories.files', $accessory);
if ($request->hasFile('file')) {
if (! Storage::exists('private_uploads/accessories')) {
Storage::makeDirectory('private_uploads/accessories', 775);
}
foreach ($request->file('file') as $file) {
$file_name = $request->handleFile('private_uploads/accessories/', 'accessory-'.$accessory->id, $file);
//Log the upload to the log
$accessory->logUpload($file_name, e($request->input('notes')));
}
return redirect()->route('accessories.show', $accessory->id)->with('success', trans('general.file_upload_success'));
}
return redirect()->route('accessories.show', $accessory->id)->with('error', trans('general.no_files_uploaded'));
}
// Prepare the error message
return redirect()->route('accessories.index')
->with('error', trans('general.file_does_not_exist'));
}
/**
* Deletes the selected accessory file.
*
* @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v1.0]
* @param int $accessoryId
* @param int $fileId
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function destroy($accessoryId = null, $fileId = null)
{
$accessory = Accessory::find($accessoryId);
// the asset is valid
if (isset($accessory->id)) {
$this->authorize('update', $accessory);
$log = Actionlog::find($fileId);
// Remove the file if one exists
if (Storage::exists('accessories/'.$log->filename)) {
try {
Storage::delete('accessories/'.$log->filename);
} catch (\Exception $e) {
Log::debug($e);
}
}
$log->delete();
return redirect()->back()
->with('success', trans('admin/hardware/message.deletefile.success'));
}
// Redirect to the licence management page
return redirect()->route('accessories.index')->with('error', trans('general.file_does_not_exist'));
}
/**
* Allows the selected file to be viewed.
*
* @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v1.4]
* @param int $accessoryId
* @param int $fileId
* @return \Symfony\Accessory\HttpFoundation\Response
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function show($accessoryId = null, $fileId = null, $download = true)
{
Log::debug('Private filesystem is: '.config('filesystems.default'));
$accessory = Accessory::find($accessoryId);
// the accessory is valid
if (isset($accessory->id)) {
$this->authorize('view', $accessory);
$this->authorize('accessories.files', $accessory);
if (! $log = Actionlog::whereNotNull('filename')->where('item_id', $accessory->id)->find($fileId)) {
return redirect()->route('accessories.index')->with('error', trans('admin/users/message.log_record_not_found'));
}
$file = 'private_uploads/accessories/'.$log->filename;
if (Storage::missing($file)) {
Log::debug('FILE DOES NOT EXISTS for '.$file);
Log::debug('URL should be '.Storage::url($file));
return response('File '.$file.' ('.Storage::url($file).') not found on server', 404)
->header('Content-Type', 'text/plain');
} else {
// Display the file inline
if (request('inline') == 'true') {
$headers = [
'Content-Disposition' => 'inline',
];
return Storage::download($file, $log->filename, $headers);
}
// We have to override the URL stuff here, since local defaults in Laravel's Flysystem
// won't work, as they're not accessible via the web
if (config('filesystems.default') == 'local') { // TODO - is there any way to fix this at the StorageHelper layer?
return StorageHelper::downloader($file);
}
}
}
return redirect()->route('accessories.index')->with('error', trans('general.file_does_not_exist', ['id' => $fileId]));
}
}
@@ -3,14 +3,12 @@
namespace App\Http\Controllers\Accessories; namespace App\Http\Controllers\Accessories;
use App\Events\CheckoutableCheckedIn; use App\Events\CheckoutableCheckedIn;
use App\Helpers\Helper;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Accessory; use App\Models\Accessory;
use App\Models\AccessoryCheckout; use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use \Illuminate\Contracts\View\View;
use \Illuminate\Http\RedirectResponse;
class AccessoryCheckinController extends Controller class AccessoryCheckinController extends Controller
{ {
@@ -21,25 +19,22 @@ class AccessoryCheckinController extends Controller
* @param Request $request * @param Request $request
* @param int $accessoryUserId * @param int $accessoryUserId
* @param string $backto * @param string $backto
* @return View
* @internal param int $accessoryId
* @throws \Illuminate\Auth\Access\AuthorizationException
*/ */
public function create($accessoryUserId = null, $backto = null) : View | RedirectResponse public function create($accessoryUserId = null, $backto = null)
{ {
if (is_null($accessory_user = DB::table('accessories_checkout')->find($accessoryUserId))) { // Check if the accessory exists
if (is_null($accessory_user = DB::table('accessories_users')->find($accessoryUserId))) {
// Redirect to the accessory management page with error
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.not_found')); return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.not_found'));
} }
$accessory = Accessory::find($accessory_user->accessory_id); $accessory = Accessory::find($accessory_user->accessory_id);
//based on what the accessory is checked out to the target redirect option will be displayed accordingly.
$target_option = match ($accessory_user->assigned_type) {
'App\Models\Asset' => trans('admin/hardware/form.redirect_to_type', ['type' => trans('general.asset')]),
'App\Models\Location' => trans('admin/hardware/form.redirect_to_type', ['type' => trans('general.location')]),
default => trans('admin/hardware/form.redirect_to_type', ['type' => trans('general.user')]),
};
$this->authorize('checkin', $accessory); $this->authorize('checkin', $accessory);
return view('accessories/checkin', compact('accessory', 'target_option'))->with('backto', $backto); return view('accessories/checkin', compact('accessory'))->with('backto', $backto);
} }
/** /**
@@ -47,25 +42,24 @@ class AccessoryCheckinController extends Controller
* *
* @uses Accessory::checkin_email() to determine if an email can and should be sent * @uses Accessory::checkin_email() to determine if an email can and should be sent
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @param null $accessoryCheckoutId * @param null $accessoryUserId
* @param string $backto * @param string $backto
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
* @internal param int $accessoryId
*/ */
public function store(Request $request, $accessoryCheckoutId = null, $backto = null) : RedirectResponse public function store(Request $request, $accessoryUserId = null, $backto = null)
{ {
if (is_null($accessory_checkout = AccessoryCheckout::find($accessoryCheckoutId))) { // Check if the accessory exists
if (is_null($accessory_user = DB::table('accessories_users')->find($accessoryUserId))) {
// Redirect to the accessory management page with error
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.does_not_exist')); return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.does_not_exist'));
} }
$accessory = Accessory::find($accessory_checkout->accessory_id); $accessory = Accessory::find($accessory_user->accessory_id);
session()->put('checkedInFrom', $accessory_checkout->assigned_to);
session()->put('checkout_to_type', match ($accessory_checkout->assigned_type) {
'App\Models\User' => 'user',
'App\Models\Location' => 'location',
'App\Models\Asset' => 'asset',
});
$this->authorize('checkin', $accessory); $this->authorize('checkin', $accessory);
$checkin_hours = date('H:i:s'); $checkin_hours = date('H:i:s');
$checkin_at = date('Y-m-d H:i:s'); $checkin_at = date('Y-m-d H:i:s');
if ($request->filled('checkin_at')) { if ($request->filled('checkin_at')) {
@@ -73,13 +67,12 @@ class AccessoryCheckinController extends Controller
} }
// Was the accessory updated? // Was the accessory updated?
if ($accessory_checkout->delete()) { if (DB::table('accessories_users')->where('id', '=', $accessory_user->id)->delete()) {
event(new CheckoutableCheckedIn($accessory, $accessory_checkout->assignedTo, auth()->user(), $request->input('note'), $checkin_at)); $return_to = e($accessory_user->assigned_to);
session()->put(['redirect_option' => $request->get('redirect_option')]); event(new CheckoutableCheckedIn($accessory, User::find($return_to), Auth::user(), $request->input('note'), $checkin_at));
return Helper::getRedirectOption($request, $accessory->id, 'Accessories') return redirect()->route('accessories.show', $accessory->id)->with('success', trans('admin/accessories/message.checkin.success'));
->with('success', trans('admin/accessories/message.checkin.success'));
} }
// Redirect to the accessory management page with error // Redirect to the accessory management page with error
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.checkin.error')); return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.checkin.error'));
@@ -3,34 +3,29 @@
namespace App\Http\Controllers\Accessories; namespace App\Http\Controllers\Accessories;
use App\Events\CheckoutableCheckedOut; use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\AccessoryCheckoutRequest;
use App\Models\Accessory; use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\User; use App\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use \Illuminate\Contracts\View\View; use Illuminate\Support\Facades\DB;
use \Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Input;
class AccessoryCheckoutController extends Controller class AccessoryCheckoutController extends Controller
{ {
use CheckInOutRequest;
/** /**
* Return the form to checkout an Accessory to a user. * Return the form to checkout an Accessory to a user.
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @param int $id * @param int $id
* @return View
* @throws \Illuminate\Auth\Access\AuthorizationException
*/ */
public function create($id) : View | RedirectResponse public function create($id)
{ {
if ($accessory = Accessory::withCount('checkouts as checkouts_count')->find($id)) { if ($accessory = Accessory::withCount('users as users_count')->find($id)) {
$this->authorize('checkout', $accessory); $this->authorize('checkout', $accessory);
@@ -63,42 +58,46 @@ class AccessoryCheckoutController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @param Request $request * @param Request $request
* @param Accessory $accessory * @param int $accessoryId
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/ */
public function store(AccessoryCheckoutRequest $request, Accessory $accessory) : RedirectResponse public function store(Request $request, $accessoryId)
{ {
// Check if the accessory exists
$this->authorize('checkout', $accessory); if (is_null($accessory = Accessory::withCount('users as users_count')->find($accessoryId))) {
// Redirect to the accessory management page with error
$target = $this->determineCheckoutTarget(); return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.user_not_found'));
session()->put(['checkout_to_type' => $target]);
$accessory->checkout_qty = $request->input('checkout_qty', 1);
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
$accessory_checkout = new AccessoryCheckout([
'accessory_id' => $accessory->id,
'created_at' => Carbon::now(),
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
]);
$accessory_checkout->created_by = auth()->id();
$accessory_checkout->save();
} }
event(new CheckoutableCheckedOut($accessory, $target, auth()->user(), $request->input('note'))); $this->authorize('checkout', $accessory);
$request->request->add(['checkout_to_type' => request('checkout_to_type')]); if (!$user = User::find($request->input('assigned_to'))) {
$request->request->add(['assigned_to' => $target->id]); return redirect()->route('accessories.checkout.show', $accessory->id)->with('error', trans('admin/accessories/message.checkout.user_does_not_exist'));
}
session()->put(['redirect_option' => $request->get('redirect_option'), 'checkout_to_type' => $request->get('checkout_to_type')]); // Make sure there is at least one available to checkout
if ($accessory->numRemaining() <= 0){
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.checkout.unavailable'));
}
// Update the accessory data
$accessory->assigned_to = e($request->input('assigned_to'));
$accessory->users()->attach($accessory->id, [
'accessory_id' => $accessory->id,
'created_at' => Carbon::now(),
'user_id' => Auth::id(),
'assigned_to' => $request->get('assigned_to'),
'note' => $request->input('note'),
]);
DB::table('accessories_users')->where('assigned_to', '=', $accessory->assigned_to)->where('accessory_id', '=', $accessory->id)->first();
event(new CheckoutableCheckedOut($accessory, $user, Auth::user(), $request->input('note')));
// Redirect to the new accessory page // Redirect to the new accessory page
return Helper::getRedirectOption($request, $accessory->id, 'Accessories') return redirect()->route('accessories.index')->with('success', trans('admin/accessories/message.checkout.success'));
->with('success', trans('admin/accessories/message.checkout.success'));
} }
} }
@@ -7,7 +7,6 @@ use App\Events\CheckoutDeclined;
use App\Events\ItemAccepted; use App\Events\ItemAccepted;
use App\Events\ItemDeclined; use App\Events\ItemDeclined;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Mail\CheckoutAcceptanceResponseMail;
use App\Models\Actionlog; use App\Models\Actionlog;
use App\Models\Asset; use App\Models\Asset;
use App\Models\CheckoutAcceptance; use App\Models\CheckoutAcceptance;
@@ -21,30 +20,28 @@ use App\Models\License;
use App\Models\Component; use App\Models\Component;
use App\Models\Consumable; use App\Models\Consumable;
use App\Notifications\AcceptanceAssetAcceptedNotification; use App\Notifications\AcceptanceAssetAcceptedNotification;
use App\Notifications\AcceptanceAssetAcceptedToUserNotification;
use App\Notifications\AcceptanceAssetDeclinedNotification; use App\Notifications\AcceptanceAssetDeclinedNotification;
use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Http\Controllers\SettingsController; use App\Http\Controllers\SettingsController;
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon; use Carbon\Carbon;
use \Illuminate\Contracts\View\View; use phpDocumentor\Reflection\Types\Compound;
use \Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use TCPDF;
use App\Helpers\Helper;
class AcceptanceController extends Controller class AcceptanceController extends Controller
{ {
/** /**
* Show a listing of pending checkout acceptances for the current user * Show a listing of pending checkout acceptances for the current user
*
* @return View
*/ */
public function index() : View public function index()
{ {
$acceptances = CheckoutAcceptance::forUser(auth()->user())->pending()->get(); $acceptances = CheckoutAcceptance::forUser(Auth::user())->pending()->get();
return view('account/accept.index', compact('acceptances')); return view('account/accept.index', compact('acceptances'));
} }
@@ -52,8 +49,9 @@ class AcceptanceController extends Controller
* Shows a form to either accept or decline the checkout acceptance * Shows a form to either accept or decline the checkout acceptance
* *
* @param int $id * @param int $id
* @return mixed
*/ */
public function create($id) : View | RedirectResponse public function create($id)
{ {
$acceptance = CheckoutAcceptance::find($id); $acceptance = CheckoutAcceptance::find($id);
@@ -66,7 +64,7 @@ class AcceptanceController extends Controller
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted')); return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted'));
} }
if (! $acceptance->isCheckedOutTo(auth()->user())) { if (! $acceptance->isCheckedOutTo(Auth::user())) {
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted')); return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
} }
@@ -82,8 +80,9 @@ class AcceptanceController extends Controller
* *
* @param Request $request * @param Request $request
* @param int $id * @param int $id
* @return \Illuminate\Http\RedirectResponse
*/ */
public function store(Request $request, $id) : RedirectResponse public function store(Request $request, $id)
{ {
$acceptance = CheckoutAcceptance::find($id); $acceptance = CheckoutAcceptance::find($id);
@@ -95,7 +94,7 @@ class AcceptanceController extends Controller
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted')); return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted'));
} }
if (! $acceptance->isCheckedOutTo(auth()->user())) { if (! $acceptance->isCheckedOutTo(Auth::user())) {
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted')); return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
} }
@@ -153,8 +152,6 @@ class AcceptanceController extends Controller
} }
} }
$assigned_user = User::find($acceptance->assigned_to_id);
// this is horrible // this is horrible
switch($acceptance->checkoutable_type){ switch($acceptance->checkoutable_type){
case 'App\Models\Asset': case 'App\Models\Asset':
@@ -164,30 +161,35 @@ class AcceptanceController extends Controller
return redirect()->back()->with('error', trans('admin/models/message.does_not_exist')); return redirect()->back()->with('error', trans('admin/models/message.does_not_exist'));
} }
$display_model = $asset_model->name; $display_model = $asset_model->name;
$assigned_to = User::find($acceptance->assigned_to_id)->present()->fullName;
break; break;
case 'App\Models\Accessory': case 'App\Models\Accessory':
$pdf_view_route ='account.accept.accept-accessory-eula'; $pdf_view_route ='account.accept.accept-accessory-eula';
$accessory = Accessory::find($item->id); $accessory = Accessory::find($item->id);
$display_model = $accessory->name; $display_model = $accessory->name;
$assigned_to = User::find($acceptance->assigned_to_id)->present()->fullName;
break; break;
case 'App\Models\LicenseSeat': case 'App\Models\LicenseSeat':
$pdf_view_route ='account.accept.accept-license-eula'; $pdf_view_route ='account.accept.accept-license-eula';
$license = License::find($item->license_id); $license = License::find($item->license_id);
$display_model = $license->name; $display_model = $license->name;
$assigned_to = User::find($acceptance->assigned_to_id)->present()->fullName;
break; break;
case 'App\Models\Component': case 'App\Models\Component':
$pdf_view_route = 'account.accept.accept-component-eula'; $pdf_view_route ='account.accept.accept-component-eula';
$component = Component::find($item->id); $component = Component::find($item->id);
$display_model = $component->name; $display_model = $component->name;
$assigned_to = User::find($acceptance->assigned_to_id)->present()->fullName;
break; break;
case 'App\Models\Consumable': case 'App\Models\Consumable':
$pdf_view_route ='account.accept.accept-consumable-eula'; $pdf_view_route ='account.accept.accept-consumable-eula';
$consumable = Consumable::find($item->id); $consumable = Consumable::find($item->id);
$display_model = $consumable->name; $display_model = $consumable->name;
$assigned_to = User::find($acceptance->assigned_to_id)->present()->fullName;
break; break;
} }
// if ($acceptance->checkoutable_type == 'App\Models\Asset') { // if ($acceptance->checkoutable_type == 'App\Models\Asset') {
@@ -210,132 +212,35 @@ class AcceptanceController extends Controller
*/ */
$branding_settings = SettingsController::getPDFBranding(); $branding_settings = SettingsController::getPDFBranding();
$path_logo = ""; if (is_null($branding_settings->logo)){
$path_logo = "";
// Check for the PDF logo path and use that, otherwise use the regular logo path } else {
if (!is_null($branding_settings->acceptance_pdf_logo)) {
$path_logo = public_path() . '/uploads/' . $branding_settings->acceptance_pdf_logo;
} elseif (!is_null($branding_settings->logo)) {
$path_logo = public_path() . '/uploads/' . $branding_settings->logo; $path_logo = public_path() . '/uploads/' . $branding_settings->logo;
} }
$data = [ $data = [
'item_tag' => $item->asset_tag, 'item_tag' => $item->asset_tag,
'item_model' => $display_model, 'item_model' => $display_model,
'item_serial' => $item->serial, 'item_serial' => $item->serial,
'item_status' => $item->assetstatus?->name,
'eula' => $item->getEula(), 'eula' => $item->getEula(),
'note' => $request->input('note'), 'note' => $request->input('note'),
'check_out_date' => Carbon::parse($acceptance->created_at)->format('Y-m-d H:i:s'), 'check_out_date' => Carbon::parse($acceptance->created_at)->format('Y-m-d'),
'accepted_date' => Carbon::parse($acceptance->accepted_at)->format('Y-m-d H:i:s'), 'accepted_date' => Carbon::parse($acceptance->accepted_at)->format('Y-m-d'),
'assigned_to' => $assigned_user->display_name, 'assigned_to' => $assigned_to,
'company_name' => $branding_settings->site_name, 'company_name' => $branding_settings->site_name,
'signature' => ($sig_filename) ? storage_path() . '/private_uploads/signatures/' . $sig_filename : null, 'signature' => ($sig_filename) ? storage_path() . '/private_uploads/signatures/' . $sig_filename : null,
'logo' => $path_logo, 'logo' => $path_logo,
'date_settings' => $branding_settings->date_display_format, 'date_settings' => $branding_settings->date_display_format,
'admin' => auth()->user()->present()?->fullName,
]; ];
if ($pdf_view_route!='') { if ($pdf_view_route!='') {
Log::debug($pdf_filename.' is the filename, and the route was specified.'); Log::debug($pdf_filename.' is the filename, and the route was specified.');
//$pdf = new PDF; $pdf = Pdf::loadView($pdf_view_route, $data);
Storage::put('private_uploads/eula-pdfs/' .$pdf_filename, $pdf->output());
// set some language dependent data:
$lg = Array();
$lg['a_meta_charset'] = 'UTF-8';
$lg['w_page'] = 'page';
$pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8', false);
// $pdf->SetHeaderData(PDF_HEADER_LOGO, 5, PDF_HEADER_TITLE.' 006', PDF_HEADER_STRING);
// $pdf->SetHeaderData('https://snipe-it.test/uploads/snipe-logo.png', '5', $data['company_name'], $item->company?->name);
//$pdf->headerText = ('Anything you want ' . date('c'));
$pdf->setRTL(false);
//$pdf->SetHeaderData(PDF_HEADER_LOGO, PDF_HEADER_LOGO_WIDTH, $data['company_name'], '');
$pdf->setLanguageArray($lg);
$pdf->SetCreator('Snipe-IT');
$pdf->SetAuthor($data['assigned_to']);
$pdf->SetTitle('Asset Acceptance: '.$data['item_tag']);
// $pdf->SetSubject('Document Subject');
//$pdf->SetKeywords('keywords, here');
$pdf->SetFont('dejavusans', '', 8, '', true);
$pdf->SetPrintHeader(false);
$pdf->SetPrintFooter(false);
$pdf->setHeaderFont(Array(PDF_FONT_NAME_MAIN, '', PDF_FONT_SIZE_MAIN));
$pdf->setFooterFont(Array(PDF_FONT_NAME_DATA, '', PDF_FONT_SIZE_DATA));
$pdf->AddPage();
$pdf->writeHTML('<img src="'.$path_logo.'" height="30">', true, 0, true, 0, '');
// $pdf->writeHTML(trans('general.date').': '.date($data['date_settings']), true, 0, true, 0, '');
$pdf->writeHTML("<strong>".trans('general.asset_tag').'</strong>: '.$data['item_tag'], true, 0, true, 0, '');
$pdf->writeHTML("<strong>".trans('general.asset_model').'</strong>: '.$data['item_model'], true, 0, true, 0, '');
$pdf->writeHTML("<strong>".trans('admin/hardware/form.serial').'</strong>: '.$data['item_serial'], true, 0, true, 0, '');
$pdf->writeHTML("<strong>".trans('general.assigned_date').'</strong>: '.$data['check_out_date'], true, 0, true, 0, '');
$pdf->writeHTML("<strong>".trans('general.assignee').'</strong>: '.$data['assigned_to'], true, 0, true, 0, '');
$pdf->Ln();
// $html = view($pdf_view_route, $data)->render();
// $pdf->writeHTML($html, true, 0, true, 0, '');
// $eula_lines = explode("\n\n", $item->getEula());
$eula_lines = preg_split("/\r\n|\n|\r/", $item->getEula());
foreach ($eula_lines as $eula_line) {
if (Helper::hasRtl($eula_line)) {
$pdf->setRTL(true);
} else {
$pdf->setRTL(false);
}
$pdf->writeHTML(Helper::parseEscapedMarkedown($eula_line), true, 0, true, 0, '');
}
$pdf->Ln();
$pdf->Ln();
$pdf->setRTL(false);
$pdf->writeHTML('<br><br>', true, 0, true, 0, '');
if ($data['note'] != null) {
$pdf->writeHTML("<strong>".trans('general.notes') . '</strong>: ' . $data['note'], true, 0, true, 0, '');
$pdf->Ln();
}
if ($data['signature'] != null) {
$pdf->writeHTML('<img src="'.$data['signature'].'" style="max-width: 600px;">', true, 0, true, 0, '');
$pdf->writeHTML('<hr>', true, 0, true, 0, '');
}
$pdf->writeHTML("<strong>".trans('general.accepted_date').'</strong>: '.$data['accepted_date'], true, 0, true, 0, '');
$pdf_content = $pdf->Output($pdf_filename, 'S');
//$html = view($pdf_view_route, $data)->render();
//$pdf = PDF::writeHTML($html, true, false, true, false, '');
Storage::put('private_uploads/eula-pdfs/' .$pdf_filename, $pdf_content);
} }
// $acceptance->accept($sig_filename, $item->getEula(), $pdf_filename, $request->input('note')); $acceptance->accept($sig_filename, $item->getEula(), $pdf_filename, $request->input('note'));
$acceptance->notify(new AcceptanceAssetAcceptedNotification($data));
// Send the PDF to the signing user
if (($request->input('send_copy') == '1') && ($assigned_user->email !='')) {
// Add the attachment for the signing user into the $data array
$data['file'] = $pdf_filename;
$locale = $assigned_user->locale;
try {
$assigned_user->notify((new AcceptanceAssetAcceptedToUserNotification($data))->locale($locale));
} catch (\Exception $e) {
Log::warning($e);
}
}
try {
$acceptance->notify((new AcceptanceAssetAcceptedNotification($data))->locale(Setting::getSettings()->locale));
} catch (\Exception $e) {
Log::warning($e);
}
event(new CheckoutAccepted($acceptance)); event(new CheckoutAccepted($acceptance));
$return_msg = trans('admin/users/message.accepted'); $return_msg = trans('admin/users/message.accepted');
@@ -407,7 +312,6 @@ class AcceptanceController extends Controller
'item_tag' => $item->asset_tag, 'item_tag' => $item->asset_tag,
'item_model' => $display_model, 'item_model' => $display_model,
'item_serial' => $item->serial, 'item_serial' => $item->serial,
'item_status' => $item->assetstatus?->name,
'note' => $request->input('note'), 'note' => $request->input('note'),
'declined_date' => Carbon::parse($acceptance->declined_at)->format('Y-m-d'), 'declined_date' => Carbon::parse($acceptance->declined_at)->format('Y-m-d'),
'signature' => ($sig_filename) ? storage_path() . '/private_uploads/signatures/' . $sig_filename : null, 'signature' => ($sig_filename) ? storage_path() . '/private_uploads/signatures/' . $sig_filename : null,
@@ -424,32 +328,12 @@ class AcceptanceController extends Controller
$acceptance->decline($sig_filename, $request->input('note')); $acceptance->decline($sig_filename, $request->input('note'));
$acceptance->notify(new AcceptanceAssetDeclinedNotification($data)); $acceptance->notify(new AcceptanceAssetDeclinedNotification($data));
Log::debug('New event acceptance.');
event(new CheckoutDeclined($acceptance)); event(new CheckoutDeclined($acceptance));
$return_msg = trans('admin/users/message.declined'); $return_msg = trans('admin/users/message.declined');
} }
if ($acceptance->alert_on_response_id) {
try {
$recipient = User::find($acceptance->alert_on_response_id);
if ($recipient) {
Log::debug('Attempting to send email acceptance.');
Mail::to($recipient)->send(new CheckoutAcceptanceResponseMail(
$acceptance,
$recipient,
$request->input('asset_acceptance') === 'accepted',
));
Log::debug('Send email notification sucess on checkout acceptance response.');
}
} catch (Exception $e) {
Log::error($e->getMessage());
Log::warning($e);
}
}
return redirect()->to('account/accept')->with('success', $return_msg); return redirect()->to('account/accept')->with('success', $return_msg);
} }
} }
+7 -18
View File
@@ -3,14 +3,13 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Helpers\Helper; use App\Helpers\Helper;
use Illuminate\Http\RedirectResponse; use App\Models\Actionlog;
use Illuminate\Support\Facades\Response;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use \Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class ActionlogController extends Controller class ActionlogController extends Controller
{ {
public function displaySig($filename) : RedirectResponse | Response | bool public function displaySig($filename)
{ {
// PHP doesn't let you handle file not found errors well with // PHP doesn't let you handle file not found errors well with
// file_get_contents, so we set the error reporting for just this class // file_get_contents, so we set the error reporting for just this class
@@ -18,7 +17,6 @@ class ActionlogController extends Controller
$disk = config('filesystems.default'); $disk = config('filesystems.default');
switch (config("filesystems.disks.$disk.driver")) { switch (config("filesystems.disks.$disk.driver")) {
case 's3': case 's3':
$file = 'private_uploads/signatures/'.$filename; $file = 'private_uploads/signatures/'.$filename;
return redirect()->away(Storage::disk($disk)->temporaryUrl($file, now()->addMinutes(5))); return redirect()->away(Storage::disk($disk)->temporaryUrl($file, now()->addMinutes(5)));
@@ -32,24 +30,15 @@ class ActionlogController extends Controller
Log::warning('File '.$file.' not found'); Log::warning('File '.$file.' not found');
return false; return false;
} else { } else {
return response()->make($contents)->header('Content-Type', $filetype); return Response::make($contents)->header('Content-Type', $filetype);
} }
} }
} }
public function getStoredEula($filename) : Response | BinaryFileResponse | RedirectResponse public function getStoredEula($filename){
{
$this->authorize('view', \App\Models\Asset::class); $this->authorize('view', \App\Models\Asset::class);
$file = config('app.private_uploads').'/eula-pdfs/'.$filename;
if (config('filesystems.default') == 's3_private') { return Response::download($file);
return redirect()->away(Storage::disk('s3_private')->temporaryUrl('private_uploads/eula-pdfs/'.$filename, now()->addMinutes(5)));
}
if (Storage::exists('private_uploads/eula-pdfs/'.$filename)) {
return response()->download(config('app.private_uploads').'/eula-pdfs/'.$filename);
}
return redirect()->back()->with('error', trans('general.file_does_not_exist'));
} }
} }
@@ -4,27 +4,20 @@ namespace App\Http\Controllers\Api;
use App\Events\CheckoutableCheckedOut; use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper; use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\AccessoryCheckoutRequest;
use App\Http\Requests\StoreAccessoryRequest;
use App\Http\Transformers\AccessoriesTransformer; use App\Http\Transformers\AccessoriesTransformer;
use App\Http\Transformers\SelectlistTransformer; use App\Http\Transformers\SelectlistTransformer;
use App\Models\Accessory; use App\Models\Accessory;
use App\Models\Company; use App\Models\Company;
use App\Models\User; use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Requests\ImageUploadRequest; use App\Http\Requests\ImageUploadRequest;
use App\Models\AccessoryCheckout;
class AccessoriesController extends Controller class AccessoriesController extends Controller
{ {
use CheckInOutRequest;
/** /**
* Display a listing of the resource. * Display a listing of the resource.
* *
@@ -52,21 +45,20 @@ class AccessoriesController extends Controller
'min_amt', 'min_amt',
'company_id', 'company_id',
'notes', 'notes',
'checkouts_count', 'users_count',
'qty', 'qty',
]; ];
$accessories = Accessory::select('accessories.*') $accessories = Accessory::select('accessories.*')->with('category', 'company', 'manufacturer', 'users', 'location', 'supplier')
->with('category', 'company', 'manufacturer', 'checkouts', 'location', 'supplier', 'adminuser') ->withCount('users as users_count');
->withCount('checkouts as checkouts_count');
if ($request->filled('search')) { if ($request->filled('search')) {
$accessories = $accessories->TextSearch($request->input('search')); $accessories = $accessories->TextSearch($request->input('search'));
} }
if ($request->filled('company_id')) { if ($request->filled('company_id')) {
$accessories->where('accessories.company_id', '=', $request->input('company_id')); $accessories->where('company_id', '=', $request->input('company_id'));
} }
if ($request->filled('category_id')) { if ($request->filled('category_id')) {
@@ -112,10 +104,7 @@ class AccessoriesController extends Controller
break; break;
case 'supplier': case 'supplier':
$accessories = $accessories->OrderSupplier($order); $accessories = $accessories->OrderSupplier($order);
break; break;
case 'created_by':
$accessories = $accessories->OrderByCreatedByName($order);
break;
default: default:
$accessories = $accessories->orderBy($column_sort, $order); $accessories = $accessories->orderBy($column_sort, $order);
break; break;
@@ -131,13 +120,14 @@ class AccessoriesController extends Controller
/** /**
* Store a newly created resource in storage. * Store a newly created resource in storage.
* *
* @param \App\Http\Requests\ImageUploadRequest $request
* @return \Illuminate\Http\JsonResponse
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param \App\Http\Requests\ImageUploadRequest $request
* @return \Illuminate\Http\Response
*/ */
public function store(StoreAccessoryRequest $request) public function store(ImageUploadRequest $request)
{ {
$this->authorize('create', Accessory::class);
$accessory = new Accessory; $accessory = new Accessory;
$accessory->fill($request->all()); $accessory->fill($request->all());
$accessory = $request->handleImages($accessory); $accessory = $request->handleImages($accessory);
@@ -153,15 +143,15 @@ class AccessoriesController extends Controller
/** /**
* Display the specified resource. * Display the specified resource.
* *
* @param int $id
* @return array
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id
* @return \Illuminate\Http\Response
*/ */
public function show($id) public function show($id)
{ {
$this->authorize('view', Accessory::class); $this->authorize('view', Accessory::class);
$accessory = Accessory::withCount('checkouts as checkouts_count')->findOrFail($id); $accessory = Accessory::withCount('users as users_count')->findOrFail($id);
return (new AccessoriesTransformer)->transformAccessory($accessory); return (new AccessoriesTransformer)->transformAccessory($accessory);
} }
@@ -170,10 +160,10 @@ class AccessoriesController extends Controller
/** /**
* Display the specified resource. * Display the specified resource.
* *
* @param int $id
* @return array
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id
* @return \Illuminate\Http\Response
*/ */
public function accessory_detail($id) public function accessory_detail($id)
{ {
@@ -185,33 +175,47 @@ class AccessoriesController extends Controller
/** /**
* Get the list of checkouts for a specific accessory * Display the specified resource.
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return | array * @return \Illuminate\Http\Response
*/ */
public function checkedout(Request $request, $id) public function checkedout($id, Request $request)
{ {
$this->authorize('view', Accessory::class); $this->authorize('view', Accessory::class);
$accessory = Accessory::with('lastCheckout')->findOrFail($id); $accessory = Accessory::with('lastCheckout')->findOrFail($id);
if (! Company::isCurrentUserHasAccess($accessory)) {
return ['total' => 0, 'rows' => []];
}
$offset = request('offset', 0); $offset = request('offset', 0);
$limit = request('limit', 50); $limit = request('limit', 50);
// Total count of all checkouts for this asset $accessory_users = $accessory->users;
$accessory_checkouts = $accessory->checkouts(); $total = $accessory_users->count();
// Check for search text in the request if ($total < $offset) {
if ($request->filled('search')) { $offset = 0;
$accessory_checkouts = $accessory_checkouts->TextSearch($request->input('search'));
} }
$total = $accessory_checkouts->count(); $accessory_users = $accessory->users()->skip($offset)->take($limit)->get();
$accessory_checkouts = $accessory_checkouts->skip($offset)->take($limit)->get();
return (new AccessoriesTransformer)->transformCheckedoutAccessory($accessory_checkouts, $total); if ($request->filled('search')) {
$accessory_users = $accessory->users()
->where(function ($query) use ($request) {
$search_str = '%' . $request->input('search') . '%';
$query->where('first_name', 'like', $search_str)
->orWhere('last_name', 'like', $search_str)
->orWhere('note', 'like', $search_str);
})
->get();
$total = $accessory_users->count();
}
return (new AccessoriesTransformer)->transformCheckedoutAccessory($accessory, $accessory_users, $total);
} }
@@ -222,7 +226,7 @@ class AccessoriesController extends Controller
* @since [v4.0] * @since [v4.0]
* @param \App\Http\Requests\ImageUploadRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @param int $id * @param int $id
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\Response
*/ */
public function update(ImageUploadRequest $request, $id) public function update(ImageUploadRequest $request, $id)
{ {
@@ -244,16 +248,16 @@ class AccessoriesController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\Response
*/ */
public function destroy($id) public function destroy($id)
{ {
$this->authorize('delete', Accessory::class); $this->authorize('delete', Accessory::class);
$accessory = Accessory::withCount('checkouts as checkouts_count')->findOrFail($id); $accessory = Accessory::findOrFail($id);
$this->authorize($accessory); $this->authorize($accessory);
if ($accessory->checkouts_count > 0) { if ($accessory->hasUsers() > 0) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/accessories/general.delete_disabled'))); return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/accessories/message.assoc_users', ['count'=> $accessory->hasUsers()])));
} }
$accessory->delete(); $accessory->delete();
@@ -268,83 +272,83 @@ class AccessoriesController extends Controller
* If Slack is enabled and/or asset acceptance is enabled, it will also * If Slack is enabled and/or asset acceptance is enabled, it will also
* trigger a Slack message and send an email. * trigger a Slack message and send an email.
* *
* @param int $accessoryId
* @return \Illuminate\Http\JsonResponse
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @param int $accessoryId
* @return \Illuminate\Http\RedirectResponse
*/ */
public function checkout(AccessoryCheckoutRequest $request, Accessory $accessory) public function checkout(Request $request, $accessoryId)
{ {
$this->authorize('checkout', $accessory); // Check if the accessory exists
$target = $this->determineCheckoutTarget(); if (is_null($accessory = Accessory::withCount('users as users_count')->find($accessoryId))) {
$accessory->checkout_qty = $request->input('checkout_qty', 1); return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/accessories/message.does_not_exist')));
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
$accessory_checkout = new AccessoryCheckout([
'accessory_id' => $accessory->id,
'created_at' => Carbon::now(),
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
]);
$accessory_checkout->created_by = auth()->id();
$accessory_checkout->save();
$payload = [
'accessory_id' => $accessory->id,
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
'created_by' => auth()->id(),
'pivot' => $accessory_checkout->id,
];
} }
// Set this value to be able to pass the qty through to the event $this->authorize('checkout', $accessory);
event(new CheckoutableCheckedOut($accessory, $target, auth()->user(), $request->input('note')));
return response()->json(Helper::formatStandardApiResponse('success', $payload, trans('admin/accessories/message.checkout.success')));
if ($accessory->numRemaining() > 0) {
if (! $user = User::find($request->input('assigned_to'))) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/accessories/message.checkout.user_does_not_exist')));
}
// Update the accessory data
$accessory->assigned_to = $request->input('assigned_to');
$accessory->users()->attach($accessory->id, [
'accessory_id' => $accessory->id,
'created_at' => Carbon::now(),
'user_id' => Auth::id(),
'assigned_to' => $request->get('assigned_to'),
'note' => $request->get('note'),
]);
event(new CheckoutableCheckedOut($accessory, $user, Auth::user(), $request->input('note')));
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/accessories/message.checkout.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, 'No accessories remaining'));
} }
/** /**
* Check in the item so that it can be checked out again to someone else * Check in the item so that it can be checked out again to someone else
* *
* @uses Accessory::checkin_email() to determine if an email can and should be sent
* @author [A. Gianotto] [<snipe@snipe.net>]
* @param Request $request * @param Request $request
* @param int $accessoryUserId * @param int $accessoryUserId
* @param string $backto * @param string $backto
* @return JsonResponse * @return \Illuminate\Http\RedirectResponse
* @uses Accessory::checkin_email() to determine if an email can and should be sent
* @author [A. Gianotto] [<snipe@snipe.net>]
* @internal param int $accessoryId * @internal param int $accessoryId
*/ */
public function checkin(Request $request, $accessoryUserId = null) public function checkin(Request $request, $accessoryUserId = null)
{ {
if (is_null($accessory_checkout = AccessoryCheckout::find($accessoryUserId))) { if (is_null($accessory_user = DB::table('accessories_users')->find($accessoryUserId))) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/accessories/message.does_not_exist', ['id' => $accessoryUserId]))); return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/accessories/message.does_not_exist')));
} }
$accessory = Accessory::find($accessory_checkout->accessory_id); $accessory = Accessory::find($accessory_user->accessory_id);
$this->authorize('checkin', $accessory); $this->authorize('checkin', $accessory);
$accessory->logCheckin(User::find($accessory_checkout->assigned_to), $request->input('note')); $logaction = $accessory->logCheckin(User::find($accessory_user->assigned_to), $request->input('note'));
// Was the accessory updated? // Was the accessory updated?
if ($accessory_checkout->delete()) { if (DB::table('accessories_users')->where('id', '=', $accessory_user->id)->delete()) {
if (! is_null($accessory_checkout->assigned_to)) { if (! is_null($accessory_user->assigned_to)) {
$user = User::find($accessory_checkout->assigned_to); $user = User::find($accessory_user->assigned_to);
} }
$payload = [ $data['log_id'] = $logaction->id;
'accessory_id' => $accessory->id, $data['first_name'] = $user->first_name;
'note' => $request->input('note'), $data['last_name'] = $user->last_name;
'created_by' => auth()->id(), $data['item_name'] = $accessory->name;
'pivot' => $accessory_checkout->id, $data['checkin_date'] = $logaction->created_at;
]; $data['item_tag'] = '';
$data['note'] = $logaction->note;
return response()->json(Helper::formatStandardApiResponse('success', $payload, trans('admin/accessories/message.checkin.success'))); return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/accessories/message.checkin.success')));
} }
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/accessories/message.checkin.error'))); return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/accessories/message.checkin.error')));
@@ -0,0 +1,219 @@
<?php
namespace App\Http\Controllers\Api;
use App\Helpers\StorageHelper;
use Illuminate\Support\Facades\Storage;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Gate;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Actionlog;
use \Illuminate\Support\Facades\Auth;
use Carbon\Carbon;
use DB;
use Illuminate\Http\Request;
use App\Http\Requests\UploadFileRequest;
use Illuminate\Support\Facades\Log;
use Input;
use Paginator;
use Slack;
use Str;
use TCPDF;
use Validator;
use Route;
/**
* This class controls file related actions related
* to assets for the Snipe-IT Asset Management application.
*
* Based on the Assets/AssetFilesController by A. Gianotto <snipe@snipe.net>
*
* @version v1.0
* @author [T. Scarsbrook] [<snipe@scarzybrook.co.uk>]
*/
class AssetFilesController extends Controller
{
/**
* Accepts a POST to upload a file to the server.
*
* @param \App\Http\Requests\UploadFileRequest $request
* @param int $assetId
* @return \Illuminate\Http\JsonResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
* @since [v6.0]
* @author [T. Scarsbrook] [<snipe@scarzybrook.co.uk>]
*/
public function store(UploadFileRequest $request, $assetId = null)
{
// Start by checking if the asset being acted upon exists
if (! $asset = Asset::find($assetId)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 404);
}
// Make sure we are allowed to update this asset
$this->authorize('update', $asset);
if ($request->hasFile('file')) {
// If the file storage directory doesn't exist; create it
if (! Storage::exists('private_uploads/assets')) {
Storage::makeDirectory('private_uploads/assets', 775);
}
// Loop over the attached files and add them to the asset
foreach ($request->file('file') as $file) {
$file_name = $request->handleFile('private_uploads/assets/','hardware-'.$asset->id, $file);
$asset->logUpload($file_name, e($request->get('notes')));
}
// All done - report success
return response()->json(Helper::formatStandardApiResponse('success', $asset, trans('admin/hardware/message.upload.success')));
}
// We only reach here if no files were included in the POST, so tell the user this
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.upload.nofiles')), 500);
}
/**
* List the files for an asset.
*
* @param int $assetId
* @return \Illuminate\Http\JsonResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
* @since [v6.0]
* @author [T. Scarsbrook] [<snipe@scarzybrook.co.uk>]
*/
public function list($assetId = null)
{
// Start by checking if the asset being acted upon exists
if (! $asset = Asset::find($assetId)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 404);
}
// the asset is valid
if (isset($asset->id)) {
$this->authorize('view', $asset);
// Check that there are some uploads on this asset that can be listed
if ($asset->uploads->count() > 0) {
$files = array();
foreach ($asset->uploads as $upload) {
array_push($files, $upload);
}
// Give the list of files back to the user
return response()->json(Helper::formatStandardApiResponse('success', $files, trans('admin/hardware/message.upload.success')));
}
// There are no files.
return response()->json(Helper::formatStandardApiResponse('success', array(), trans('admin/hardware/message.upload.success')));
}
// Send back an error message
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.download.error')), 500);
}
/**
* Check for permissions and display the file.
*
* @param int $assetId
* @param int $fileId
* @return \Illuminate\Http\JsonResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
* @since [v6.0]
* @author [T. Scarsbrook] [<snipe@scarzybrook.co.uk>]
*/
public function show($assetId = null, $fileId = null)
{
// Start by checking if the asset being acted upon exists
if (! $asset = Asset::find($assetId)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 404);
}
// the asset is valid
if (isset($asset->id)) {
$this->authorize('view', $asset);
// Check that the file being requested exists for the asset
if (! $log = Actionlog::whereNotNull('filename')->where('item_id', $asset->id)->find($fileId)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.download.no_match', ['id' => $fileId])), 404);
}
// Form the full filename with path
$file = 'private_uploads/assets/'.$log->filename;
\Log::debug('Checking for '.$file);
if ($log->action_type == 'audit') {
$file = 'private_uploads/audits/'.$log->filename;
}
// Check the file actually exists on the filesystem
if (! Storage::exists($file)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.download.does_not_exist', ['id' => $fileId])), 404);
}
if (request('inline') == 'true') {
$headers = [
'Content-Disposition' => 'inline',
];
return Storage::download($file, $log->filename, $headers);
}
return StorageHelper::downloader($file);
}
// Send back an error message
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.download.error', ['id' => $fileId])), 500);
}
/**
* Delete the associated file
*
* @param int $assetId
* @param int $fileId
* @return \Illuminate\Http\JsonResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
* @since [v6.0]
* @author [T. Scarsbrook] [<snipe@scarzybrook.co.uk>]
*/
public function destroy($assetId = null, $fileId = null)
{
// Start by checking if the asset being acted upon exists
if (! $asset = Asset::find($assetId)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 404);
}
$rel_path = 'private_uploads/assets';
// the asset is valid
if (isset($asset->id)) {
$this->authorize('update', $asset);
// Check for the file
$log = Actionlog::find($fileId);
if ($log) {
// Check the file actually exists, and delete it
if (Storage::exists($rel_path.'/'.$log->filename)) {
Storage::delete($rel_path.'/'.$log->filename);
}
// Delete the record of the file
$log->delete();
// All deleting done - notify the user of success
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/hardware/message.deletefile.success')), 200);
}
// The file doesn't seem to really exist, so report an error
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.deletefile.error')), 500);
}
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.deletefile.error')), 500);
}
}
@@ -4,13 +4,14 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper; use App\Helpers\Helper;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\ImageUploadRequest; use App\Http\Transformers\AssetMaintenancesTransformer;
use App\Http\Transformers\MaintenancesTransformer;
use App\Models\Asset; use App\Models\Asset;
use App\Models\Maintenance; use App\Models\AssetMaintenance;
use App\Models\Company; use App\Models\Company;
use Illuminate\Support\Facades\Auth;
use Carbon\Carbon;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Input;
/** /**
* This controller handles all actions related to Asset Maintenance for * This controller handles all actions related to Asset Maintenance for
@@ -18,23 +19,25 @@ use Illuminate\Http\JsonResponse;
* *
* @version v2.0 * @version v2.0
*/ */
class MaintenancesController extends Controller class AssetMaintenancesController extends Controller
{ {
/** /**
* Generates the JSON response for asset maintenances listing view. * Generates the JSON response for asset maintenances listing view.
* *
* @see MaintenancesController::getIndex() method that generates view * @see AssetMaintenancesController::getIndex() method that generates view
* @author Vincent Sposato <vincent.sposato@gmail.com> * @author Vincent Sposato <vincent.sposato@gmail.com>
* @version v1.0 * @version v1.0
* @since [v1.8] * @since [v1.8]
* @return string JSON
*/ */
public function index(Request $request) : JsonResponse | array public function index(Request $request)
{ {
$this->authorize('view', Asset::class); $this->authorize('view', Asset::class);
$maintenances = Maintenance::select('maintenances.*') $maintenances = AssetMaintenance::select('asset_maintenances.*')
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.assetstatus', 'adminuser'); ->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.assetstatus', 'admin');
if ($request->filled('search')) { if ($request->filled('search')) {
$maintenances = $maintenances->TextSearch($request->input('search')); $maintenances = $maintenances->TextSearch($request->input('search'));
@@ -45,11 +48,7 @@ class MaintenancesController extends Controller
} }
if ($request->filled('supplier_id')) { if ($request->filled('supplier_id')) {
$maintenances->where('maintenances.supplier_id', '=', $request->input('supplier_id')); $maintenances->where('asset_maintenances.supplier_id', '=', $request->input('supplier_id'));
}
if ($request->filled('created_by')) {
$maintenances->where('maintenances.created_by', '=', $request->input('created_by'));
} }
if ($request->filled('asset_maintenance_type')) { if ($request->filled('asset_maintenance_type')) {
@@ -63,7 +62,7 @@ class MaintenancesController extends Controller
$allowed_columns = [ $allowed_columns = [
'id', 'id',
'name', 'title',
'asset_maintenance_time', 'asset_maintenance_time',
'asset_maintenance_type', 'asset_maintenance_type',
'cost', 'cost',
@@ -73,9 +72,8 @@ class MaintenancesController extends Controller
'asset_tag', 'asset_tag',
'asset_name', 'asset_name',
'serial', 'serial',
'created_by', 'user_id',
'supplier', 'supplier',
'location',
'is_warranty', 'is_warranty',
'status_label', 'status_label',
]; ];
@@ -84,8 +82,8 @@ class MaintenancesController extends Controller
$sort = in_array($request->input('sort'), $allowed_columns) ? e($request->input('sort')) : 'created_at'; $sort = in_array($request->input('sort'), $allowed_columns) ? e($request->input('sort')) : 'created_at';
switch ($sort) { switch ($sort) {
case 'created_by': case 'user_id':
$maintenances = $maintenances->OrderByCreatedBy($order); $maintenances = $maintenances->OrderAdmin($order);
break; break;
case 'supplier': case 'supplier':
$maintenances = $maintenances->OrderBySupplier($order); $maintenances = $maintenances->OrderBySupplier($order);
@@ -99,9 +97,6 @@ class MaintenancesController extends Controller
case 'serial': case 'serial':
$maintenances = $maintenances->OrderByAssetSerial($order); $maintenances = $maintenances->OrderByAssetSerial($order);
break; break;
case 'location':
$maintenances = $maintenances->OrderLocationName($order);
break;
case 'status_label': case 'status_label':
$maintenances = $maintenances->OrderStatusName($order); $maintenances = $maintenances->OrderStatusName($order);
break; break;
@@ -112,7 +107,7 @@ class MaintenancesController extends Controller
$total = $maintenances->count(); $total = $maintenances->count();
$maintenances = $maintenances->skip($offset)->take($limit)->get(); $maintenances = $maintenances->skip($offset)->take($limit)->get();
return (new MaintenancesTransformer())->transformMaintenances($maintenances, $total); return (new AssetMaintenancesTransformer())->transformAssetMaintenances($maintenances, $total);
} }
@@ -121,23 +116,23 @@ class MaintenancesController extends Controller
/** /**
* Validates and stores the new asset maintenance * Validates and stores the new asset maintenance
* *
* @see MaintenancesController::getCreate() method for the form * @see AssetMaintenancesController::getCreate() method for the form
* @author Vincent Sposato <vincent.sposato@gmail.com> * @author Vincent Sposato <vincent.sposato@gmail.com>
* @version v1.0 * @version v1.0
* @since [v1.8] * @since [v1.8]
* @return string JSON
*/ */
public function store(ImageUploadRequest $request) : JsonResponse | array public function store(Request $request)
{ {
$this->authorize('update', Asset::class); $this->authorize('update', Asset::class);
// create a new model instance // create a new model instance
$maintenance = new Maintenance(); $maintenance = new AssetMaintenance();
$maintenance->fill($request->all()); $maintenance->fill($request->all());
$maintenance->created_by = auth()->id(); $maintenance->user_id = Auth::id();
$maintenance = $request->handleImages($maintenance);
// Was the asset maintenance created? // Was the asset maintenance created?
if ($maintenance->save()) { if ($maintenance->save()) {
return response()->json(Helper::formatStandardApiResponse('success', $maintenance, trans('admin/maintenances/message.create.success'))); return response()->json(Helper::formatStandardApiResponse('success', $maintenance, trans('admin/asset_maintenances/message.create.success')));
} }
@@ -153,16 +148,17 @@ class MaintenancesController extends Controller
* @param int $request * @param int $request
* @version v1.0 * @version v1.0
* @since [v4.0] * @since [v4.0]
* @return string JSON
*/ */
public function update(Request $request, $id) : JsonResponse | array public function update(Request $request, $id)
{ {
$this->authorize('update', Asset::class); $this->authorize('update', Asset::class);
if ($maintenance = Maintenance::with('asset')->find($id)) { if ($maintenance = AssetMaintenance::with('asset')->find($id)) {
// Can this user manage this asset? // Can this user manage this asset?
if (! Company::isCurrentUserHasAccess($maintenance->asset)) { if (! Company::isCurrentUserHasAccess($maintenance->asset)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.action_permission_denied', ['item_type' => trans('admin/maintenances/general.maintenance'), 'id' => $id, 'action' => trans('general.edit')]))); return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.action_permission_denied', ['item_type' => trans('admin/asset_maintenances/general.maintenance'), 'id' => $id, 'action' => trans('general.edit')])));
} }
// The asset this miantenance is attached to is not valid or has been deleted // The asset this miantenance is attached to is not valid or has been deleted
@@ -173,13 +169,13 @@ class MaintenancesController extends Controller
$maintenance->fill($request->all()); $maintenance->fill($request->all());
if ($maintenance->save()) { if ($maintenance->save()) {
return response()->json(Helper::formatStandardApiResponse('success', $maintenance, trans('admin/maintenances/message.edit.success'))); return response()->json(Helper::formatStandardApiResponse('success', $maintenance, trans('admin/asset_maintenances/message.edit.success')));
} }
return response()->json(Helper::formatStandardApiResponse('error', null, $maintenance->getErrors())); return response()->json(Helper::formatStandardApiResponse('error', null, $maintenance->getErrors()));
} }
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.item_not_found', ['item_type' => trans('admin/maintenances/general.maintenance'), 'id' => $id]))); return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.item_not_found', ['item_type' => trans('admin/asset_maintenances/general.maintenance'), 'id' => $id])));
} }
@@ -187,20 +183,24 @@ class MaintenancesController extends Controller
* Delete an asset maintenance * Delete an asset maintenance
* *
* @author A. Gianotto <snipe@snipe.net> * @author A. Gianotto <snipe@snipe.net>
* @param int $maintenanceId * @param int $assetMaintenanceId
* @version v1.0 * @version v1.0
* @since [v4.0] * @since [v4.0]
* @return string JSON
*/ */
public function destroy($maintenanceId) : JsonResponse | array public function destroy($assetMaintenanceId)
{ {
$this->authorize('update', Asset::class); $this->authorize('update', Asset::class);
// Check if the asset maintenance exists // Check if the asset maintenance exists
$assetMaintenance = AssetMaintenance::findOrFail($assetMaintenanceId);
$maintenance = Maintenance::findOrFail($maintenanceId); if (! Company::isCurrentUserHasAccess($assetMaintenance->asset)) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'You cannot delete a maintenance for that asset'));
}
$maintenance->delete(); $assetMaintenance->delete();
return response()->json(Helper::formatStandardApiResponse('success', $maintenance, trans('admin/maintenances/message.delete.success'))); return response()->json(Helper::formatStandardApiResponse('success', $assetMaintenance, trans('admin/asset_maintenances/message.delete.success')));
} }
@@ -209,19 +209,20 @@ class MaintenancesController extends Controller
* View an asset maintenance * View an asset maintenance
* *
* @author A. Gianotto <snipe@snipe.net> * @author A. Gianotto <snipe@snipe.net>
* @param int $maintenanceId * @param int $assetMaintenanceId
* @version v1.0 * @version v1.0
* @since [v4.0] * @since [v4.0]
* @return string JSON
*/ */
public function show($maintenanceId) : JsonResponse | array public function show($assetMaintenanceId)
{ {
$this->authorize('view', Asset::class); $this->authorize('view', Asset::class);
$maintenance = Maintenance::findOrFail($maintenanceId); $assetMaintenance = AssetMaintenance::findOrFail($assetMaintenanceId);
if (! Company::isCurrentUserHasAccess($maintenance->asset)) { if (! Company::isCurrentUserHasAccess($assetMaintenance->asset)) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'You cannot view a maintenance for that asset')); return response()->json(Helper::formatStandardApiResponse('error', null, 'You cannot view a maintenance for that asset'));
} }
return (new MaintenancesTransformer())->transformMaintenance($maintenance); return (new AssetMaintenancesTransformer())->transformAssetMaintenance($assetMaintenance);
} }
} }
@@ -4,16 +4,15 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper; use App\Helpers\Helper;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\StoreAssetModelRequest;
use App\Http\Transformers\AssetModelsTransformer; use App\Http\Transformers\AssetModelsTransformer;
use App\Http\Transformers\AssetsTransformer; use App\Http\Transformers\AssetsTransformer;
use App\Http\Transformers\SelectlistTransformer; use App\Http\Transformers\SelectlistTransformer;
use App\Models\Asset; use App\Models\Asset;
use App\Models\AssetModel; use App\Models\AssetModel;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Requests\ImageUploadRequest;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Http\JsonResponse;
/** /**
* This class controls all actions related to asset models for * This class controls all actions related to asset models for
@@ -29,8 +28,9 @@ class AssetModelsController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @return \Illuminate\Http\Response
*/ */
public function index(Request $request) : JsonResponse | array public function index(Request $request)
{ {
$this->authorize('view', AssetModel::class); $this->authorize('view', AssetModel::class);
$allowed_columns = $allowed_columns =
@@ -48,63 +48,36 @@ class AssetModelsController extends Controller
'assets_count', 'assets_count',
'category', 'category',
'fieldset', 'fieldset',
'deleted_at',
'updated_at',
'require_serial',
]; ];
$assetmodels = AssetModel::select([ $assetmodels = AssetModel::select([
'models.id', 'models.id',
'models.image', 'models.image',
'models.name', 'models.name',
'models.model_number', 'model_number',
'models.min_amt', 'min_amt',
'models.eol', 'eol',
'models.created_by', 'requestable',
'models.requestable',
'models.notes', 'models.notes',
'models.created_at', 'models.created_at',
'models.category_id', 'category_id',
'models.manufacturer_id', 'manufacturer_id',
'models.depreciation_id', 'depreciation_id',
'models.fieldset_id', 'fieldset_id',
'models.deleted_at', 'models.deleted_at',
'models.updated_at', 'models.updated_at',
'models.require_serial'
]) ])
->with('category', 'depreciation', 'manufacturer', 'fieldset.fields.defaultValues', 'adminuser') ->with('category', 'depreciation', 'manufacturer', 'fieldset.fields.defaultValues')
->withCount('assets as assets_count'); ->withCount('assets as assets_count');
if ($request->input('status')=='deleted') { if ($request->input('status')=='deleted') {
$assetmodels->onlyTrashed(); $assetmodels->onlyTrashed();
} }
if ($request->filled('name')) {
$assetmodels = $assetmodels->where('models.name', '=', $request->input('name'));
}
if ($request->filled('model_number')) {
$assetmodels = $assetmodels->where('models.model_number', '=', $request->input('model_number'));
}
if ($request->input('requestable') == 'true') {
$assetmodels = $assetmodels->where('models.requestable', '=', '1');
} elseif ($request->input('requestable') == 'false') {
$assetmodels = $assetmodels->where('models.requestable', '=', '0');
}
if ($request->filled('notes')) {
$assetmodels = $assetmodels->where('models.notes', '=', $request->input('notes'));
}
if ($request->filled('category_id')) { if ($request->filled('category_id')) {
$assetmodels = $assetmodels->where('models.category_id', '=', $request->input('category_id')); $assetmodels = $assetmodels->where('models.category_id', '=', $request->input('category_id'));
} }
if ($request->filled('depreciation_id')) {
$assetmodels = $assetmodels->where('models.depreciation_id', '=', $request->input('depreciation_id'));
}
if ($request->filled('search')) { if ($request->filled('search')) {
$assetmodels->TextSearch($request->input('search')); $assetmodels->TextSearch($request->input('search'));
} }
@@ -116,7 +89,7 @@ class AssetModelsController extends Controller
$order = $request->input('order') === 'asc' ? 'asc' : 'desc'; $order = $request->input('order') === 'asc' ? 'asc' : 'desc';
$sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'models.created_at'; $sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'models.created_at';
switch ($request->input('sort')) { switch ($sort) {
case 'manufacturer': case 'manufacturer':
$assetmodels->OrderManufacturer($order); $assetmodels->OrderManufacturer($order);
break; break;
@@ -126,9 +99,6 @@ class AssetModelsController extends Controller
case 'fieldset': case 'fieldset':
$assetmodels->OrderFieldset($order); $assetmodels->OrderFieldset($order);
break; break;
case 'created_by':
$assetmodels->OrderByCreatedByName($order);
break;
default: default:
$assetmodels->orderBy($sort, $order); $assetmodels->orderBy($sort, $order);
break; break;
@@ -146,9 +116,10 @@ class AssetModelsController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param \App\Http\Requests\StoreAssetModelRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @return \Illuminate\Http\Response
*/ */
public function store(StoreAssetModelRequest $request) : JsonResponse public function store(ImageUploadRequest $request)
{ {
$this->authorize('create', AssetModel::class); $this->authorize('create', AssetModel::class);
$assetmodel = new AssetModel; $assetmodel = new AssetModel;
@@ -156,7 +127,7 @@ class AssetModelsController extends Controller
$assetmodel = $request->handleImages($assetmodel); $assetmodel = $request->handleImages($assetmodel);
if ($assetmodel->save()) { if ($assetmodel->save()) {
return response()->json(Helper::formatStandardApiResponse('success', (new AssetModelsTransformer)->transformAssetModel($assetmodel), trans('admin/models/message.create.success'))); return response()->json(Helper::formatStandardApiResponse('success', $assetmodel, trans('admin/models/message.create.success')));
} }
return response()->json(Helper::formatStandardApiResponse('error', null, $assetmodel->getErrors())); return response()->json(Helper::formatStandardApiResponse('error', null, $assetmodel->getErrors()));
@@ -169,8 +140,9 @@ class AssetModelsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function show($id) : array public function show($id)
{ {
$this->authorize('view', AssetModel::class); $this->authorize('view', AssetModel::class);
$assetmodel = AssetModel::withCount('assets as assets_count')->findOrFail($id); $assetmodel = AssetModel::withCount('assets as assets_count')->findOrFail($id);
@@ -184,8 +156,9 @@ class AssetModelsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function assets($id) : array public function assets($id)
{ {
$this->authorize('view', AssetModel::class); $this->authorize('view', AssetModel::class);
$assets = Asset::where('model_id', '=', $id)->get(); $assets = Asset::where('model_id', '=', $id)->get();
@@ -203,13 +176,13 @@ class AssetModelsController extends Controller
* @param int $id * @param int $id
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*/ */
public function update(StoreAssetModelRequest $request, $id) : JsonResponse public function update(ImageUploadRequest $request, $id)
{ {
$this->authorize('update', AssetModel::class); $this->authorize('update', AssetModel::class);
$assetmodel = AssetModel::findOrFail($id); $assetmodel = AssetModel::findOrFail($id);
$assetmodel->fill($request->all()); $assetmodel->fill($request->all());
$assetmodel = $request->handleImages($assetmodel); $assetmodel = $request->handleImages($assetmodel);
/** /**
* Allow custom_fieldset_id to override and populate fieldset_id. * Allow custom_fieldset_id to override and populate fieldset_id.
* This is stupid, but required for legacy API support. * This is stupid, but required for legacy API support.
@@ -224,7 +197,7 @@ class AssetModelsController extends Controller
if ($assetmodel->save()) { if ($assetmodel->save()) {
return response()->json(Helper::formatStandardApiResponse('success', (new AssetModelsTransformer)->transformAssetModel($assetmodel), trans('admin/models/message.update.success'))); return response()->json(Helper::formatStandardApiResponse('success', $assetmodel, trans('admin/models/message.update.success')));
} }
return response()->json(Helper::formatStandardApiResponse('error', null, $assetmodel->getErrors())); return response()->json(Helper::formatStandardApiResponse('error', null, $assetmodel->getErrors()));
@@ -236,8 +209,9 @@ class AssetModelsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function destroy($id) : JsonResponse public function destroy($id)
{ {
$this->authorize('delete', AssetModel::class); $this->authorize('delete', AssetModel::class);
$assetmodel = AssetModel::findOrFail($id); $assetmodel = AssetModel::findOrFail($id);
@@ -267,7 +241,7 @@ class AssetModelsController extends Controller
* @since [v4.0.16] * @since [v4.0.16]
* @see \App\Http\Transformers\SelectlistTransformer * @see \App\Http\Transformers\SelectlistTransformer
*/ */
public function selectlist(Request $request) : array public function selectlist(Request $request)
{ {
$this->authorize('view.selectlists'); $this->authorize('view.selectlists');
File diff suppressed because it is too large Load Diff
@@ -8,9 +8,9 @@ use App\Http\Transformers\CategoriesTransformer;
use App\Http\Transformers\SelectlistTransformer; use App\Http\Transformers\SelectlistTransformer;
use App\Models\Category; use App\Models\Category;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Requests\ImageUploadRequest; use App\Http\Requests\ImageUploadRequest;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
class CategoriesController extends Controller class CategoriesController extends Controller
{ {
@@ -21,7 +21,7 @@ class CategoriesController extends Controller
* @since [v4.0] * @since [v4.0]
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*/ */
public function index(Request $request) : array public function index(Request $request)
{ {
$this->authorize('view', Category::class); $this->authorize('view', Category::class);
$allowed_columns = [ $allowed_columns = [
@@ -39,12 +39,10 @@ class CategoriesController extends Controller
'components_count', 'components_count',
'licenses_count', 'licenses_count',
'image', 'image',
'notes',
]; ];
$categories = Category::select([ $categories = Category::select([
'id', 'id',
'created_by',
'created_at', 'created_at',
'updated_at', 'updated_at',
'name', 'category_type', 'name', 'category_type',
@@ -52,11 +50,8 @@ class CategoriesController extends Controller
'eula_text', 'eula_text',
'require_acceptance', 'require_acceptance',
'checkin_email', 'checkin_email',
'image', 'image'
'notes', ])->withCount('accessories as accessories_count', 'consumables as consumables_count', 'components as components_count', 'licenses as licenses_count');
])
->with('adminuser')
->withCount('accessories as accessories_count', 'consumables as consumables_count', 'components as components_count', 'licenses as licenses_count', 'models as models_count');
/* /*
@@ -96,33 +91,13 @@ class CategoriesController extends Controller
$categories->where('checkin_email', '=', $request->input('checkin_email')); $categories->where('checkin_email', '=', $request->input('checkin_email'));
} }
if ($request->filled('created_by')) {
$categories->where('created_by', '=', $request->input('created_by'));
}
if ($request->filled('created_at')) {
$categories->where('created_at', '=', $request->input('created_at'));
}
if ($request->filled('updated_at')) {
$categories->where('updated_at', '=', $request->input('updated_at'));
}
// Make sure the offset and limit are actually integers and do not exceed system limits // Make sure the offset and limit are actually integers and do not exceed system limits
$offset = ($request->input('offset') > $categories->count()) ? $categories->count() : app('api_offset_value'); $offset = ($request->input('offset') > $categories->count()) ? $categories->count() : app('api_offset_value');
$limit = app('api_limit_value'); $limit = app('api_limit_value');
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
$sort_override = $request->input('sort');
$column_sort = in_array($sort_override, $allowed_columns) ? $sort_override : 'assets_count';
switch ($sort_override) { $order = $request->input('order') === 'asc' ? 'asc' : 'desc';
case 'created_by': $sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'assets_count';
$categories = $categories->OrderByCreatedBy($order); $categories->orderBy($sort, $order);
break;
default:
$categories = $categories->orderBy($column_sort, $order);
break;
}
$total = $categories->count(); $total = $categories->count();
$categories = $categories->skip($offset)->take($limit)->get(); $categories = $categories->skip($offset)->take($limit)->get();
@@ -140,7 +115,7 @@ class CategoriesController extends Controller
* @param \App\Http\Requests\ImageUploadRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*/ */
public function store(ImageUploadRequest $request) : JsonResponse public function store(ImageUploadRequest $request)
{ {
$this->authorize('create', Category::class); $this->authorize('create', Category::class);
$category = new Category; $category = new Category;
@@ -161,8 +136,9 @@ class CategoriesController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function show($id) : array public function show($id)
{ {
$this->authorize('view', Category::class); $this->authorize('view', Category::class);
$category = Category::withCount('assets as assets_count', 'accessories as accessories_count', 'consumables as consumables_count', 'components as components_count', 'licenses as licenses_count')->findOrFail($id); $category = Category::withCount('assets as assets_count', 'accessories as accessories_count', 'consumables as consumables_count', 'components as components_count', 'licenses as licenses_count')->findOrFail($id);
@@ -180,7 +156,7 @@ class CategoriesController extends Controller
* @param int $id * @param int $id
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*/ */
public function update(ImageUploadRequest $request, $id) : JsonResponse public function update(ImageUploadRequest $request, $id)
{ {
$this->authorize('update', Category::class); $this->authorize('update', Category::class);
$category = Category::findOrFail($id); $category = Category::findOrFail($id);
@@ -188,7 +164,7 @@ class CategoriesController extends Controller
// Don't allow the user to change the category_type once it's been created // Don't allow the user to change the category_type once it's been created
if (($request->filled('category_type')) && ($category->category_type != $request->input('category_type'))) { if (($request->filled('category_type')) && ($category->category_type != $request->input('category_type'))) {
return response()->json( return response()->json(
Helper::formatStandardApiResponse('error', null, ['category_type' => trans('admin/categories/message.update.cannot_change_category_type')], 422) Helper::formatStandardApiResponse('error', null, trans('admin/categories/message.update.cannot_change_category_type'))
); );
} }
$category->fill($request->all()); $category->fill($request->all());
@@ -209,10 +185,10 @@ class CategoriesController extends Controller
* @param int $id * @param int $id
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*/ */
public function destroy($id) : JsonResponse public function destroy($id)
{ {
$this->authorize('delete', Category::class); $this->authorize('delete', Category::class);
$category = Category::withCount('assets as assets_count', 'accessories as accessories_count', 'consumables as consumables_count', 'components as components_count', 'licenses as licenses_count', 'models as models_count')->findOrFail($id); $category = Category::withCount('assets as assets_count', 'accessories as accessories_count', 'consumables as consumables_count', 'components as components_count', 'licenses as licenses_count')->findOrFail($id);
if (! $category->isDeletable()) { if (! $category->isDeletable()) {
return response()->json( return response()->json(
@@ -232,7 +208,7 @@ class CategoriesController extends Controller
* @since [v4.0.16] * @since [v4.0.16]
* @see \App\Http\Transformers\SelectlistTransformer * @see \App\Http\Transformers\SelectlistTransformer
*/ */
public function selectlist(Request $request, $category_type = 'asset') : array public function selectlist(Request $request, $category_type = 'asset')
{ {
$this->authorize('view.selectlists'); $this->authorize('view.selectlists');
$categories = Category::select([ $categories = Category::select([
@@ -1,44 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\CheckoutRequests\CancelCheckoutRequestAction;
use App\Actions\CheckoutRequests\CreateCheckoutRequestAction;
use App\Exceptions\AssetNotRequestable;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Models\Asset;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Exception;
class CheckoutRequest extends Controller
{
public function store(Asset $asset): JsonResponse
{
try {
CreateCheckoutRequestAction::run($asset, auth()->user());
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/hardware/message.requests.success')));
} catch (AssetNotRequestable $e) {
return response()->json(Helper::formatStandardApiResponse('error', 'Asset is not requestable'));
} catch (AuthorizationException $e) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.insufficient_permissions')));
} catch (Exception $e) {
report($e);
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.something_went_wrong')));
}
}
public function destroy(Asset $asset): JsonResponse
{
try {
CancelCheckoutRequestAction::run($asset, auth()->user());
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/hardware/message.requests.canceled')));
} catch (AuthorizationException $e) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.insufficient_permissions')));
} catch (Exception $e) {
report($e);
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.something_went_wrong')));
}
}
}
@@ -10,7 +10,6 @@ use App\Models\Company;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Requests\ImageUploadRequest; use App\Http\Requests\ImageUploadRequest;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Http\JsonResponse;
class CompaniesController extends Controller class CompaniesController extends Controller
{ {
@@ -19,8 +18,9 @@ class CompaniesController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @return \Illuminate\Http\Response
*/ */
public function index(Request $request) : JsonResponse | array public function index(Request $request)
{ {
$this->authorize('view', Company::class); $this->authorize('view', Company::class);
@@ -38,15 +38,11 @@ class CompaniesController extends Controller
'accessories_count', 'accessories_count',
'consumables_count', 'consumables_count',
'components_count', 'components_count',
'notes',
]; ];
$companies = Company::withCount(['assets as assets_count' => function ($query) { $companies = Company::withCount(['assets as assets_count' => function ($query) {
$query->AssetsForShow(); $query->AssetsForShow();
}]) }])->withCount('licenses as licenses_count', 'accessories as accessories_count', 'consumables as consumables_count', 'components as components_count', 'users as users_count');
->with('adminuser')
->withCount('licenses as licenses_count', 'accessories as accessories_count', 'consumables as consumables_count', 'components as components_count', 'users as users_count');
if ($request->filled('search')) { if ($request->filled('search')) {
$companies->TextSearch($request->input('search')); $companies->TextSearch($request->input('search'));
@@ -60,29 +56,17 @@ class CompaniesController extends Controller
$companies->where('email', '=', $request->input('email')); $companies->where('email', '=', $request->input('email'));
} }
if ($request->filled('created_by')) {
$companies->where('created_by', '=', $request->input('created_by'));
}
// Make sure the offset and limit are actually integers and do not exceed system limits // Make sure the offset and limit are actually integers and do not exceed system limits
$offset = ($request->input('offset') > $companies->count()) ? $companies->count() : app('api_offset_value'); $offset = ($request->input('offset') > $companies->count()) ? $companies->count() : app('api_offset_value');
$limit = app('api_limit_value'); $limit = app('api_limit_value');
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
$sort_override = $request->input('sort');
$column_sort = in_array($sort_override, $allowed_columns) ? $sort_override : 'created_at';
switch ($sort_override) {
case 'created_by': $order = $request->input('order') === 'asc' ? 'asc' : 'desc';
$companies = $companies->OrderByCreatedBy($order); $sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'created_at';
break; $companies->orderBy($sort, $order);
default:
$companies = $companies->orderBy($column_sort, $order);
break;
}
$total = $companies->count(); $total = $companies->count();
$companies = $companies->skip($offset)->take($limit)->get(); $companies = $companies->skip($offset)->take($limit)->get();
return (new CompaniesTransformer)->transformCompanies($companies, $total); return (new CompaniesTransformer)->transformCompanies($companies, $total);
@@ -95,8 +79,9 @@ class CompaniesController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param \App\Http\Requests\ImageUploadRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @return \Illuminate\Http\Response
*/ */
public function store(ImageUploadRequest $request) : JsonResponse public function store(ImageUploadRequest $request)
{ {
$this->authorize('create', Company::class); $this->authorize('create', Company::class);
$company = new Company; $company = new Company;
@@ -117,12 +102,12 @@ class CompaniesController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function show($id) : array public function show($id)
{ {
$this->authorize('view', Company::class); $this->authorize('view', Company::class);
$company = Company::findOrFail($id); $company = Company::findOrFail($id);
$this->authorize('view', $company);
return (new CompaniesTransformer)->transformCompany($company); return (new CompaniesTransformer)->transformCompany($company);
} }
@@ -135,12 +120,12 @@ class CompaniesController extends Controller
* @since [v4.0] * @since [v4.0]
* @param \App\Http\Requests\ImageUploadRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function update(ImageUploadRequest $request, $id) : JsonResponse public function update(ImageUploadRequest $request, $id)
{ {
$this->authorize('update', Company::class); $this->authorize('update', Company::class);
$company = Company::findOrFail($id); $company = Company::findOrFail($id);
$this->authorize('update', $company);
$company->fill($request->all()); $company->fill($request->all());
$company = $request->handleImages($company); $company = $request->handleImages($company);
@@ -159,8 +144,9 @@ class CompaniesController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function destroy($id) : JsonResponse public function destroy($id)
{ {
$this->authorize('delete', Company::class); $this->authorize('delete', Company::class);
$company = Company::findOrFail($id); $company = Company::findOrFail($id);
@@ -183,7 +169,7 @@ class CompaniesController extends Controller
* @since [v4.0.16] * @since [v4.0.16]
* @see \App\Http\Transformers\SelectlistTransformer * @see \App\Http\Transformers\SelectlistTransformer
*/ */
public function selectlist(Request $request) : array public function selectlist(Request $request)
{ {
$this->authorize('view.selectlists'); $this->authorize('view.selectlists');
$companies = Company::select([ $companies = Company::select([
@@ -193,7 +179,6 @@ class CompaniesController extends Controller
'companies.image', 'companies.image',
]); ]);
if ($request->filled('search')) { if ($request->filled('search')) {
$companies = $companies->where('companies.name', 'LIKE', '%'.$request->get('search').'%'); $companies = $companies->where('companies.name', 'LIKE', '%'.$request->get('search').'%');
} }
@@ -5,17 +5,16 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper; use App\Helpers\Helper;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Transformers\ComponentsTransformer; use App\Http\Transformers\ComponentsTransformer;
use App\Models\Company;
use App\Models\Component; use App\Models\Component;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Requests\ImageUploadRequest; use App\Http\Requests\ImageUploadRequest;
use App\Events\CheckoutableCheckedIn; use App\Events\CheckoutableCheckedIn;
use App\Events\ComponentCheckedIn;
use App\Models\Asset; use App\Models\Asset;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class ComponentsController extends Controller class ComponentsController extends Controller
{ {
@@ -25,8 +24,9 @@ class ComponentsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* *
* @return \Illuminate\Http\Response
*/ */
public function index(Request $request) : JsonResponse | array public function index(Request $request)
{ {
$this->authorize('view', Component::class); $this->authorize('view', Component::class);
@@ -38,7 +38,6 @@ class ComponentsController extends Controller
'name', 'name',
'min_amt', 'min_amt',
'order_number', 'order_number',
'model_number',
'serial', 'serial',
'purchase_date', 'purchase_date',
'purchase_cost', 'purchase_cost',
@@ -48,8 +47,7 @@ class ComponentsController extends Controller
]; ];
$components = Component::select('components.*') $components = Component::select('components.*')
->with('company', 'location', 'category', 'assets', 'supplier', 'adminuser', 'manufacturer', 'uncontrainedAssets') ->with('company', 'location', 'category', 'assets', 'supplier');
->withSum('uncontrainedAssets', 'components_assets.assigned_qty');
if ($request->filled('search')) { if ($request->filled('search')) {
$components = $components->TextSearch($request->input('search')); $components = $components->TextSearch($request->input('search'));
@@ -60,7 +58,7 @@ class ComponentsController extends Controller
} }
if ($request->filled('company_id')) { if ($request->filled('company_id')) {
$components->where('components.company_id', '=', $request->input('company_id')); $components->where('company_id', '=', $request->input('company_id'));
} }
if ($request->filled('category_id')) { if ($request->filled('category_id')) {
@@ -71,14 +69,6 @@ class ComponentsController extends Controller
$components->where('supplier_id', '=', $request->input('supplier_id')); $components->where('supplier_id', '=', $request->input('supplier_id'));
} }
if ($request->filled('manufacturer_id')) {
$components->where('manufacturer_id', '=', $request->input('manufacturer_id'));
}
if ($request->filled('model_number')) {
$components->where('model_number', '=', $request->input('model_number'));
}
if ($request->filled('location_id')) { if ($request->filled('location_id')) {
$components->where('location_id', '=', $request->input('location_id')); $components->where('location_id', '=', $request->input('location_id'));
} }
@@ -108,12 +98,6 @@ class ComponentsController extends Controller
case 'supplier': case 'supplier':
$components = $components->OrderSupplier($order); $components = $components->OrderSupplier($order);
break; break;
case 'manufacturer':
$components = $components->OrderManufacturer($order);
break;
case 'created_by':
$components = $components->OrderByCreatedBy($order);
break;
default: default:
$components = $components->orderBy($column_sort, $order); $components = $components->orderBy($column_sort, $order);
break; break;
@@ -132,8 +116,9 @@ class ComponentsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param \App\Http\Requests\ImageUploadRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @return \Illuminate\Http\Response
*/ */
public function store(ImageUploadRequest $request) : JsonResponse public function store(ImageUploadRequest $request)
{ {
$this->authorize('create', Component::class); $this->authorize('create', Component::class);
$component = new Component; $component = new Component;
@@ -152,8 +137,9 @@ class ComponentsController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function show($id) : array public function show($id)
{ {
$this->authorize('view', Component::class); $this->authorize('view', Component::class);
$component = Component::findOrFail($id); $component = Component::findOrFail($id);
@@ -170,8 +156,9 @@ class ComponentsController extends Controller
* @since [v4.0] * @since [v4.0]
* @param \App\Http\Requests\ImageUploadRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function update(ImageUploadRequest $request, $id) : JsonResponse public function update(ImageUploadRequest $request, $id)
{ {
$this->authorize('update', Component::class); $this->authorize('update', Component::class);
$component = Component::findOrFail($id); $component = Component::findOrFail($id);
@@ -192,17 +179,13 @@ class ComponentsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function destroy($id) : JsonResponse public function destroy($id)
{ {
$this->authorize('delete', Component::class); $this->authorize('delete', Component::class);
$component = Component::findOrFail($id); $component = Component::findOrFail($id);
$this->authorize('delete', $component); $this->authorize('delete', $component);
if ($component->numCheckedOut() > 0) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/components/message.delete.error_qty')));
}
$component->delete(); $component->delete();
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/components/message.delete.success'))); return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/components/message.delete.success')));
@@ -215,8 +198,9 @@ class ComponentsController extends Controller
* @since [v4.0] * @since [v4.0]
* @param Request $request * @param Request $request
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function getAssets(Request $request, $id) : array public function getAssets(Request $request, $id)
{ {
$this->authorize('view', \App\Models\Asset::class); $this->authorize('view', \App\Models\Asset::class);
@@ -257,8 +241,10 @@ class ComponentsController extends Controller
* @since [v5.1.8] * @since [v5.1.8]
* @param Request $request * @param Request $request
* @param int $componentId * @param int $componentId
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/ */
public function checkout(Request $request, $componentId) : JsonResponse public function checkout(Request $request, $componentId)
{ {
// Check if the component exists // Check if the component exists
if (!$component = Component::find($componentId)) { if (!$component = Component::find($componentId)) {
@@ -289,9 +275,9 @@ class ComponentsController extends Controller
$component->assets()->attach($component->id, [ $component->assets()->attach($component->id, [
'component_id' => $component->id, 'component_id' => $component->id,
'created_at' => Carbon::now(), 'created_at' => \Carbon::now(),
'assigned_qty' => $request->get('assigned_qty', 1), 'assigned_qty' => $request->get('assigned_qty', 1),
'created_by' => auth()->id(), 'user_id' => \Auth::id(),
'asset_id' => $request->get('assigned_to'), 'asset_id' => $request->get('assigned_to'),
'note' => $request->get('note'), 'note' => $request->get('note'),
]); ]);
@@ -311,11 +297,15 @@ class ComponentsController extends Controller
* @since [v5.1.8] * @since [v5.1.8]
* @param Request $request * @param Request $request
* @param $component_asset_id * @param $component_asset_id
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/ */
public function checkin(Request $request, $component_asset_id) : JsonResponse public function checkin(Request $request, $component_asset_id)
{ {
if ($component_assets = DB::table('components_assets')->find($component_asset_id)) { if ($component_assets = \DB::table('components_assets')->find($component_asset_id)) {
if (is_null($component = Component::find($component_assets->component_id))) { if (is_null($component = Component::find($component_assets->component_id))) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/components/message.not_found'))); return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/components/message.not_found')));
} }
@@ -323,13 +313,17 @@ class ComponentsController extends Controller
$max_to_checkin = $component_assets->assigned_qty; $max_to_checkin = $component_assets->assigned_qty;
$validator = Validator::make($request->all(), [ if ($max_to_checkin > 1) {
"checkin_qty" => "required|numeric|between:1,$max_to_checkin"
]); $validator = \Validator::make($request->all(), [
"checkin_qty" => "required|numeric|between:1,$max_to_checkin"
if ($validator->fails()) { ]);
return response()->json(Helper::formatStandardApiResponse('error', null, 'Checkin quantity must be between 1 and ' . $max_to_checkin));
if ($validator->fails()) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Checkin quantity must be between 1 and '.$max_to_checkin));
}
} }
// Validation passed, so let's figure out what we have to do here. // Validation passed, so let's figure out what we have to do here.
$qty_remaining_in_checkout = ($component_assets->assigned_qty - (int)$request->input('checkin_qty', 1)); $qty_remaining_in_checkout = ($component_assets->assigned_qty - (int)$request->input('checkin_qty', 1));
@@ -339,23 +333,28 @@ class ComponentsController extends Controller
$component_assets->assigned_qty = $qty_remaining_in_checkout; $component_assets->assigned_qty = $qty_remaining_in_checkout;
Log::debug($component_asset_id.' - '.$qty_remaining_in_checkout.' remaining in record '.$component_assets->id); Log::debug($component_asset_id.' - '.$qty_remaining_in_checkout.' remaining in record '.$component_assets->id);
DB::table('components_assets')->where('id', $component_asset_id)->update(['assigned_qty' => $qty_remaining_in_checkout]); \DB::table('components_assets')->where('id',
$component_asset_id)->update(['assigned_qty' => $qty_remaining_in_checkout]);
// If the checked-in qty is exactly the same as the assigned_qty, // If the checked-in qty is exactly the same as the assigned_qty,
// we can simply delete the associated components_assets record // we can simply delete the associated components_assets record
if ($qty_remaining_in_checkout === 0) { if ($qty_remaining_in_checkout == 0) {
DB::table('components_assets')->where('id', '=', $component_asset_id)->delete(); \DB::table('components_assets')->where('id', '=', $component_asset_id)->delete();
} }
$asset = Asset::find($component_assets->asset_id); $asset = Asset::find($component_assets->asset_id);
event(new CheckoutableCheckedIn($component, $asset, auth()->user(), $request->input('note'), Carbon::now())); event(new CheckoutableCheckedIn($component, $asset, \Auth::user(), $request->input('note'), \Carbon::now()));
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/components/message.checkin.success'))); return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/components/message.checkin.success')));
} }
return response()->json(Helper::formatStandardApiResponse('error', null, 'No matching checkouts for that component join record')); return response()->json(Helper::formatStandardApiResponse('error', null, 'No matching checkouts for that component join record'));
} }
} }
@@ -5,7 +5,6 @@ namespace App\Http\Controllers\Api;
use App\Events\CheckoutableCheckedOut; use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper; use App\Helpers\Helper;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\StoreConsumableRequest;
use App\Http\Transformers\ConsumablesTransformer; use App\Http\Transformers\ConsumablesTransformer;
use App\Http\Transformers\SelectlistTransformer; use App\Http\Transformers\SelectlistTransformer;
use App\Models\Company; use App\Models\Company;
@@ -13,8 +12,8 @@ use App\Models\Consumable;
use App\Models\User; use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Requests\ImageUploadRequest; use App\Http\Requests\ImageUploadRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Http\JsonResponse;
class ConsumablesController extends Controller class ConsumablesController extends Controller
{ {
@@ -23,13 +22,34 @@ class ConsumablesController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
*
* @return \Illuminate\Http\Response
*/ */
public function index(Request $request) : array public function index(Request $request)
{ {
$this->authorize('index', Consumable::class); $this->authorize('index', Consumable::class);
$consumables = Consumable::with('company', 'location', 'category', 'supplier', 'manufacturer') // This array is what determines which fields should be allowed to be sorted on ON the table itself, no relations
->withCount('users as consumables_users_count'); // Relations will be handled in query scopes a little further down.
$allowed_columns =
[
'id',
'name',
'order_number',
'min_amt',
'purchase_date',
'purchase_cost',
'company',
'category',
'model_number',
'item_no',
'qty',
'image',
'notes',
];
$consumables = Consumable::select('consumables.*')
->with('company', 'location', 'category', 'users', 'manufacturer');
if ($request->filled('search')) { if ($request->filled('search')) {
$consumables = $consumables->TextSearch(e($request->input('search'))); $consumables = $consumables->TextSearch(e($request->input('search')));
@@ -40,7 +60,7 @@ class ConsumablesController extends Controller
} }
if ($request->filled('company_id')) { if ($request->filled('company_id')) {
$consumables->where('consumables.company_id', '=', $request->input('company_id')); $consumables->where('company_id', '=', $request->input('company_id'));
} }
if ($request->filled('category_id')) { if ($request->filled('category_id')) {
@@ -71,9 +91,15 @@ class ConsumablesController extends Controller
// Make sure the offset and limit are actually integers and do not exceed system limits // Make sure the offset and limit are actually integers and do not exceed system limits
$offset = ($request->input('offset') > $consumables->count()) ? $consumables->count() : app('api_offset_value'); $offset = ($request->input('offset') > $consumables->count()) ? $consumables->count() : app('api_offset_value');
$limit = app('api_limit_value'); $limit = app('api_limit_value');
$allowed_columns = ['id', 'name', 'order_number', 'min_amt', 'purchase_date', 'purchase_cost', 'company', 'category', 'model_number', 'item_no', 'manufacturer', 'location', 'qty', 'image'];
$order = $request->input('order') === 'asc' ? 'asc' : 'desc'; $order = $request->input('order') === 'asc' ? 'asc' : 'desc';
switch ($request->input('sort')) { $sort_override = $request->input('sort');
$column_sort = in_array($sort_override, $allowed_columns) ? $sort_override : 'created_at';
switch ($sort_override) {
case 'category': case 'category':
$consumables = $consumables->OrderCategory($order); $consumables = $consumables->OrderCategory($order);
break; break;
@@ -86,37 +112,11 @@ class ConsumablesController extends Controller
case 'company': case 'company':
$consumables = $consumables->OrderCompany($order); $consumables = $consumables->OrderCompany($order);
break; break;
case 'remaining':
$consumables = $consumables->OrderRemaining($order);
break;
case 'supplier': case 'supplier':
$consumables = $consumables->OrderSupplier($order); $components = $consumables->OrderSupplier($order);
break;
case 'created_by':
$consumables = $consumables->OrderByCreatedBy($order);
break; break;
default: default:
// This array is what determines which fields should be allowed to be sorted on ON the table itself. $consumables = $consumables->orderBy($column_sort, $order);
// These must match a column on the consumables table directly.
$allowed_columns = [
'id',
'name',
'order_number',
'min_amt',
'purchase_date',
'purchase_cost',
'company',
'category',
'model_number',
'item_no',
'manufacturer',
'location',
'qty',
'image'
];
$sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'created_at';
$consumables = $consumables->orderBy($sort, $order);
break; break;
} }
@@ -132,8 +132,9 @@ class ConsumablesController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param \App\Http\Requests\ImageUploadRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @return \Illuminate\Http\Response
*/ */
public function store(StoreConsumableRequest $request) : JsonResponse public function store(ImageUploadRequest $request)
{ {
$this->authorize('create', Consumable::class); $this->authorize('create', Consumable::class);
$consumable = new Consumable; $consumable = new Consumable;
@@ -152,8 +153,9 @@ class ConsumablesController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function show($id) : array public function show($id)
{ {
$this->authorize('view', Consumable::class); $this->authorize('view', Consumable::class);
$consumable = Consumable::with('users')->findOrFail($id); $consumable = Consumable::with('users')->findOrFail($id);
@@ -168,8 +170,9 @@ class ConsumablesController extends Controller
* @since [v4.0] * @since [v4.0]
* @param \App\Http\Requests\ImageUploadRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function update(StoreConsumableRequest $request, $id) : JsonResponse public function update(ImageUploadRequest $request, $id)
{ {
$this->authorize('update', Consumable::class); $this->authorize('update', Consumable::class);
$consumable = Consumable::findOrFail($id); $consumable = Consumable::findOrFail($id);
@@ -189,8 +192,9 @@ class ConsumablesController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function destroy($id) : JsonResponse public function destroy($id)
{ {
$this->authorize('delete', Consumable::class); $this->authorize('delete', Consumable::class);
$consumable = Consumable::findOrFail($id); $consumable = Consumable::findOrFail($id);
@@ -207,13 +211,14 @@ class ConsumablesController extends Controller
* @see \App\Http\Controllers\Consumables\ConsumablesController::getView() method that returns the form. * @see \App\Http\Controllers\Consumables\ConsumablesController::getView() method that returns the form.
* @since [v1.0] * @since [v1.0]
* @param int $consumableId * @param int $consumableId
* @return array
*/ */
public function getDataView($consumableId) : array public function getDataView($consumableId)
{ {
$consumable = Consumable::with(['consumableAssignments'=> function ($query) { $consumable = Consumable::with(['consumableAssignments'=> function ($query) {
$query->orderBy($query->getModel()->getTable().'.created_at', 'DESC'); $query->orderBy($query->getModel()->getTable().'.created_at', 'DESC');
}, },
'consumableAssignments.adminuser'=> function ($query) { 'consumableAssignments.admin'=> function ($query) {
}, },
'consumableAssignments.user'=> function ($query) { 'consumableAssignments.user'=> function ($query) {
}, },
@@ -228,16 +233,10 @@ class ConsumablesController extends Controller
foreach ($consumable->consumableAssignments as $consumable_assignment) { foreach ($consumable->consumableAssignments as $consumable_assignment) {
$rows[] = [ $rows[] = [
'avatar' => ($consumable_assignment->user) ? e($consumable_assignment->user->present()->gravatar) : '', 'avatar' => ($consumable_assignment->user) ? e($consumable_assignment->user->present()->gravatar) : '',
'user' => ($consumable_assignment->user) ? [ 'name' => ($consumable_assignment->user) ? $consumable_assignment->user->present()->nameUrl() : 'Deleted User',
'id' => (int) $consumable_assignment->user->id,
'name'=> e($consumable_assignment->user->display_name),
] : null,
'created_at' => Helper::getFormattedDateObject($consumable_assignment->created_at, 'datetime'), 'created_at' => Helper::getFormattedDateObject($consumable_assignment->created_at, 'datetime'),
'note' => ($consumable_assignment->note) ? e($consumable_assignment->note) : null, 'note' => ($consumable_assignment->note) ? e($consumable_assignment->note) : null,
'created_by' => ($consumable_assignment->adminuser) ? [ 'admin' => ($consumable_assignment->admin) ? $consumable_assignment->admin->present()->nameUrl() : null,
'id' => (int) $consumable_assignment->adminuser->id,
'name'=> e($consumable_assignment->adminuser->display_name),
] : null,
]; ];
} }
@@ -253,8 +252,9 @@ class ConsumablesController extends Controller
* @author [A. Gutierrez] [<andres@baller.tv>] * @author [A. Gutierrez] [<andres@baller.tv>]
* @param int $id * @param int $id
* @since [v4.9.5] * @since [v4.9.5]
* @return JsonResponse
*/ */
public function checkout(Request $request, $id) : JsonResponse public function checkout(Request $request, $id)
{ {
// Check if the consumable exists // Check if the consumable exists
if (!$consumable = Consumable::with('users')->find($id)) { if (!$consumable = Consumable::with('users')->find($id)) {
@@ -263,8 +263,6 @@ class ConsumablesController extends Controller
$this->authorize('checkout', $consumable); $this->authorize('checkout', $consumable);
$consumable->checkout_qty = $request->input('checkout_qty', 1);
// Make sure there is at least one available to checkout // Make sure there is at least one available to checkout
if ($consumable->numRemaining() <= 0) { if ($consumable->numRemaining() <= 0) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/consumables/message.checkout.unavailable'))); return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/consumables/message.checkout.unavailable')));
@@ -275,35 +273,27 @@ class ConsumablesController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.invalid_item_category_single', ['type' => trans('general.consumable')]))); return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.invalid_item_category_single', ['type' => trans('general.consumable')])));
} }
// Make sure there is at least one available to checkout
if ($consumable->numRemaining() <= 0 || $consumable->checkout_qty > $consumable->numRemaining()) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/consumables/message.checkout.unavailable', ['requested' => $consumable->checkout_qty, 'remaining' => $consumable->numRemaining() ])));
}
// Check if the user exists - @TODO: this should probably be handled via validation, not here?? // Check if the user exists - @TODO: this should probably be handled via validation, not here??
if (!$user = User::find($request->input('assigned_to'))) { if (!$user = User::find($request->input('assigned_to'))) {
// Return error message // Return error message
return response()->json(Helper::formatStandardApiResponse('error', null, 'No user found')); return response()->json(Helper::formatStandardApiResponse('error', null, 'No user found'));
Log::debug('No valid user');
} }
// Update the consumable data // Update the consumable data
$consumable->assigned_to = $request->input('assigned_to'); $consumable->assigned_to = $request->input('assigned_to');
for ($i = 0; $i < $consumable->checkout_qty; $i++) { $consumable->users()->attach($consumable->id,
$consumable->users()->attach($consumable->id,
[ [
'consumable_id' => $consumable->id, 'consumable_id' => $consumable->id,
'created_by' => $user->id, 'user_id' => $user->id,
'assigned_to' => $request->input('assigned_to'), 'assigned_to' => $request->input('assigned_to'),
'note' => $request->input('note'), 'note' => $request->input('note'),
] ]
); );
}
event(new CheckoutableCheckedOut($consumable, $user, Auth::user(), $request->input('note')));
event(new CheckoutableCheckedOut($consumable, $user, auth()->user(), $request->input('note')));
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/consumables/message.checkout.success'))); return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/consumables/message.checkout.success')));
@@ -314,7 +304,7 @@ class ConsumablesController extends Controller
* *
* @see \App\Http\Transformers\SelectlistTransformer * @see \App\Http\Transformers\SelectlistTransformer
*/ */
public function selectlist(Request $request) : array public function selectlist(Request $request)
{ {
$consumables = Consumable::select([ $consumables = Consumable::select([
'consumables.id', 'consumables.id',
@@ -8,8 +8,7 @@ use App\Http\Transformers\CustomFieldsTransformer;
use App\Models\CustomField; use App\Models\CustomField;
use App\Models\CustomFieldset; use App\Models\CustomFieldset;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator; use Validator;
use Illuminate\Http\JsonResponse;
class CustomFieldsController extends Controller class CustomFieldsController extends Controller
{ {
@@ -21,7 +20,7 @@ class CustomFieldsController extends Controller
* @since [v3.0] * @since [v3.0]
* @return array * @return array
*/ */
public function index() : array public function index()
{ {
$this->authorize('index', CustomField::class); $this->authorize('index', CustomField::class);
$fields = CustomField::get(); $fields = CustomField::get();
@@ -34,8 +33,9 @@ class CustomFieldsController extends Controller
* @author [V. Cordes] [<volker@fdatek.de>] * @author [V. Cordes] [<volker@fdatek.de>]
* @param int $id * @param int $id
* @since [v4.1.10] * @since [v4.1.10]
* @return View
*/ */
public function show($id) : JsonResponse | array public function show($id)
{ {
$this->authorize('view', CustomField::class); $this->authorize('view', CustomField::class);
if ($field = CustomField::find($id)) { if ($field = CustomField::find($id)) {
@@ -52,8 +52,9 @@ class CustomFieldsController extends Controller
* @since [v4.1.10] * @since [v4.1.10]
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function update(Request $request, $id) : JsonResponse public function update(Request $request, $id)
{ {
$this->authorize('update', CustomField::class); $this->authorize('update', CustomField::class);
$field = CustomField::findOrFail($id); $field = CustomField::findOrFail($id);
@@ -85,8 +86,9 @@ class CustomFieldsController extends Controller
* @author [V. Cordes] [<volker@fdatek.de>] * @author [V. Cordes] [<volker@fdatek.de>]
* @since [v4.1.10] * @since [v4.1.10]
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/ */
public function store(Request $request) : JsonResponse public function store(Request $request)
{ {
$this->authorize('create', CustomField::class); $this->authorize('create', CustomField::class);
$field = new CustomField; $field = new CustomField;
@@ -134,7 +136,7 @@ class CustomFieldsController extends Controller
return $fieldset->fields()->sync($fields); return $fieldset->fields()->sync($fields);
} }
public function associate(Request $request, $field_id) : JsonResponse public function associate(Request $request, $field_id)
{ {
$this->authorize('update', CustomFieldset::class); $this->authorize('update', CustomFieldset::class);
@@ -153,9 +155,10 @@ class CustomFieldsController extends Controller
return response()->json(Helper::formatStandardApiResponse('success', $fieldset, trans('admin/custom_fields/message.fieldset.update.success'))); return response()->json(Helper::formatStandardApiResponse('success', $fieldset, trans('admin/custom_fields/message.fieldset.update.success')));
} }
public function disassociate(Request $request, $field_id) : JsonResponse public function disassociate(Request $request, $field_id)
{ {
$this->authorize('update', CustomFieldset::class); $this->authorize('update', CustomFieldset::class);
$field = CustomField::findOrFail($field_id); $field = CustomField::findOrFail($field_id);
$fieldset_id = $request->input('fieldset_id'); $fieldset_id = $request->input('fieldset_id');
@@ -176,8 +179,9 @@ class CustomFieldsController extends Controller
* *
* @author [Brady Wetherington] [<uberbrady@gmail.com>] * @author [Brady Wetherington] [<uberbrady@gmail.com>]
* @since [v1.8] * @since [v1.8]
* @return \Illuminate\Http\RedirectResponse
*/ */
public function destroy($field_id) : JsonResponse public function destroy($field_id)
{ {
$field = CustomField::findOrFail($field_id); $field = CustomField::findOrFail($field_id);
@@ -9,7 +9,8 @@ use App\Http\Transformers\CustomFieldsTransformer;
use App\Models\CustomFieldset; use App\Models\CustomFieldset;
use App\Models\CustomField; use App\Models\CustomField;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Redirect;
use View;
/** /**
* This controller handles all actions related to Custom Asset Fieldsets for * This controller handles all actions related to Custom Asset Fieldsets for
@@ -29,8 +30,9 @@ class CustomFieldsetsController extends Controller
* @author [Josh Gibson] * @author [Josh Gibson]
* @param int $id * @param int $id
* @since [v1.8] * @since [v1.8]
* @return View
*/ */
public function index() : array public function index()
{ {
$this->authorize('index', CustomField::class); $this->authorize('index', CustomField::class);
$fieldsets = CustomFieldset::withCount('fields as fields_count', 'models as models_count')->get(); $fieldsets = CustomFieldset::withCount('fields as fields_count', 'models as models_count')->get();
@@ -44,8 +46,9 @@ class CustomFieldsetsController extends Controller
* @author [Josh Gibson] * @author [Josh Gibson]
* @param int $id * @param int $id
* @since [v1.8] * @since [v1.8]
* @return View
*/ */
public function show($id) : JsonResponse | array public function show($id)
{ {
$this->authorize('view', CustomField::class); $this->authorize('view', CustomField::class);
if ($fieldset = CustomFieldset::find($id)) { if ($fieldset = CustomFieldset::find($id)) {
@@ -62,8 +65,9 @@ class CustomFieldsetsController extends Controller
* @since [v4.0] * @since [v4.0]
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function update(Request $request, $id) : JsonResponse public function update(Request $request, $id)
{ {
$this->authorize('update', CustomField::class); $this->authorize('update', CustomField::class);
$fieldset = CustomFieldset::findOrFail($id); $fieldset = CustomFieldset::findOrFail($id);
@@ -82,8 +86,9 @@ class CustomFieldsetsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/ */
public function store(Request $request) : JsonResponse public function store(Request $request)
{ {
$this->authorize('create', CustomField::class); $this->authorize('create', CustomField::class);
$fieldset = new CustomFieldset; $fieldset = new CustomFieldset;
@@ -113,8 +118,9 @@ class CustomFieldsetsController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @return \Illuminate\Http\RedirectResponse
*/ */
public function destroy($id) : JsonResponse public function destroy($id)
{ {
$this->authorize('delete', CustomField::class); $this->authorize('delete', CustomField::class);
$fieldset = CustomFieldset::findOrFail($id); $fieldset = CustomFieldset::findOrFail($id);
@@ -141,7 +147,7 @@ class CustomFieldsetsController extends Controller
* @param $fieldsetId * @param $fieldsetId
* @return string JSON * @return string JSON
*/ */
public function fields($id) : array public function fields($id)
{ {
$this->authorize('view', CustomField::class); $this->authorize('view', CustomField::class);
$set = CustomFieldset::findOrFail($id); $set = CustomFieldset::findOrFail($id);
@@ -158,11 +164,14 @@ class CustomFieldsetsController extends Controller
* @param $fieldsetId * @param $fieldsetId
* @return string JSON * @return string JSON
*/ */
public function fieldsWithDefaultValues($fieldsetId, $modelId) : array public function fieldsWithDefaultValues($fieldsetId, $modelId)
{ {
$this->authorize('view', CustomField::class); $this->authorize('view', CustomField::class);
$set = CustomFieldset::findOrFail($fieldsetId); $set = CustomFieldset::findOrFail($fieldsetId);
$fields = $set->fields; $fields = $set->fields;
return (new CustomFieldsTransformer)->transformCustomFieldsWithDefaultValues($fields, $modelId, $fields->count()); return (new CustomFieldsTransformer)->transformCustomFieldsWithDefaultValues($fields, $modelId, $fields->count());
} }
} }
@@ -6,11 +6,12 @@ use App\Helpers\Helper;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Transformers\DepartmentsTransformer; use App\Http\Transformers\DepartmentsTransformer;
use App\Http\Transformers\SelectlistTransformer; use App\Http\Transformers\SelectlistTransformer;
use App\Models\Company;
use App\Models\Department; use App\Models\Department;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Requests\ImageUploadRequest; use App\Http\Requests\ImageUploadRequest;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Http\JsonResponse;
class DepartmentsController extends Controller class DepartmentsController extends Controller
{ {
@@ -19,11 +20,12 @@ class DepartmentsController extends Controller
* *
* @author [Godfrey Martinez] [<snipe@snipe.net>] * @author [Godfrey Martinez] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @return \Illuminate\Http\Response
*/ */
public function index(Request $request) : JsonResponse | array public function index(Request $request)
{ {
$this->authorize('view', Department::class); $this->authorize('view', Department::class);
$allowed_columns = ['id', 'name', 'image', 'users_count', 'notes']; $allowed_columns = ['id', 'name', 'image', 'users_count'];
$departments = Department::select( $departments = Department::select(
'departments.id', 'departments.id',
@@ -35,8 +37,7 @@ class DepartmentsController extends Controller
'departments.manager_id', 'departments.manager_id',
'departments.created_at', 'departments.created_at',
'departments.updated_at', 'departments.updated_at',
'departments.image', 'departments.image'
'departments.notes',
)->with('users')->with('location')->with('manager')->with('company')->withCount('users as users_count'); )->with('users')->with('location')->with('manager')->with('company')->withCount('users as users_count');
if ($request->filled('search')) { if ($request->filled('search')) {
@@ -73,9 +74,6 @@ class DepartmentsController extends Controller
case 'manager': case 'manager':
$departments->OrderManager($order); $departments->OrderManager($order);
break; break;
case 'company':
$departments->OrderCompany($order);
break;
default: default:
$departments->orderBy($sort, $order); $departments->orderBy($sort, $order);
break; break;
@@ -93,15 +91,16 @@ class DepartmentsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param \App\Http\Requests\ImageUploadRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @return \Illuminate\Http\Response
*/ */
public function store(ImageUploadRequest $request) : JsonResponse public function store(ImageUploadRequest $request)
{ {
$this->authorize('create', Department::class); $this->authorize('create', Department::class);
$department = new Department; $department = new Department;
$department->fill($request->all()); $department->fill($request->all());
$department = $request->handleImages($department); $department = $request->handleImages($department);
$department->created_by = auth()->id(); $department->user_id = Auth::user()->id;
$department->manager_id = ($request->filled('manager_id') ? $request->input('manager_id') : null); $department->manager_id = ($request->filled('manager_id') ? $request->input('manager_id') : null);
if ($department->save()) { if ($department->save()) {
@@ -117,11 +116,13 @@ class DepartmentsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function show($id) : array public function show($id)
{ {
$this->authorize('view', Department::class); $this->authorize('view', Department::class);
$department = Department::findOrFail($id); $department = Department::findOrFail($id);
return (new DepartmentsTransformer)->transformDepartment($department); return (new DepartmentsTransformer)->transformDepartment($department);
} }
@@ -132,8 +133,9 @@ class DepartmentsController extends Controller
* @since [v5.0] * @since [v5.0]
* @param \App\Http\Requests\ImageUploadRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function update(ImageUploadRequest $request, $id) : JsonResponse public function update(ImageUploadRequest $request, $id)
{ {
$this->authorize('update', Department::class); $this->authorize('update', Department::class);
$department = Department::findOrFail($id); $department = Department::findOrFail($id);
@@ -154,8 +156,9 @@ class DepartmentsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @param int $locationId * @param int $locationId
* @since [v4.0] * @since [v4.0]
* @return \Illuminate\Http\RedirectResponse
*/ */
public function destroy($id) : JsonResponse public function destroy($id)
{ {
$department = Department::findOrFail($id); $department = Department::findOrFail($id);
@@ -177,7 +180,7 @@ class DepartmentsController extends Controller
* @since [v4.0.16] * @since [v4.0.16]
* @see \App\Http\Transformers\SelectlistTransformer * @see \App\Http\Transformers\SelectlistTransformer
*/ */
public function selectlist(Request $request) : array public function selectlist(Request $request)
{ {
$this->authorize('view.selectlists'); $this->authorize('view.selectlists');
@@ -7,7 +7,6 @@ use App\Http\Controllers\Controller;
use App\Http\Transformers\DepreciationsTransformer; use App\Http\Transformers\DepreciationsTransformer;
use App\Models\Depreciation; use App\Models\Depreciation;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class DepreciationsController extends Controller class DepreciationsController extends Controller
{ {
@@ -16,27 +15,14 @@ class DepreciationsController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @return \Illuminate\Http\Response
*/ */
public function index(Request $request) : JsonResponse | array public function index(Request $request)
{ {
$this->authorize('view', Depreciation::class); $this->authorize('view', Depreciation::class);
$allowed_columns = [ $allowed_columns = ['id','name','months','depreciation_min','created_at'];
'id',
'name',
'months',
'depreciation_min',
'depreciation_type',
'created_at',
'assets_count',
'models_count',
'licenses_count',
];
$depreciations = Depreciation::select('id','name','months','depreciation_min','depreciation_type','created_at','updated_at', 'created_by') $depreciations = Depreciation::select('id','name','months','depreciation_min','user_id','created_at','updated_at');
->with('adminuser')
->withCount('assets as assets_count')
->withCount('models as models_count')
->withCount('licenses as licenses_count');
if ($request->filled('search')) { if ($request->filled('search')) {
$depreciations = $depreciations->TextSearch($request->input('search')); $depreciations = $depreciations->TextSearch($request->input('search'));
@@ -45,18 +31,10 @@ class DepreciationsController extends Controller
// Make sure the offset and limit are actually integers and do not exceed system limits // Make sure the offset and limit are actually integers and do not exceed system limits
$offset = ($request->input('offset') > $depreciations->count()) ? $depreciations->count() : app('api_offset_value'); $offset = ($request->input('offset') > $depreciations->count()) ? $depreciations->count() : app('api_offset_value');
$limit = app('api_limit_value'); $limit = app('api_limit_value');
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
$sort_override = $request->input('sort');
$column_sort = in_array($sort_override, $allowed_columns) ? $sort_override : 'created_at';
switch ($sort_override) { $order = $request->input('order') === 'asc' ? 'asc' : 'desc';
case 'created_by': $sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'created_at';
$depreciations = $depreciations->OrderByCreatedBy($order); $depreciations->orderBy($sort, $order);
break;
default:
$depreciations = $depreciations->orderBy($column_sort, $order);
break;
}
$total = $depreciations->count(); $total = $depreciations->count();
$depreciations = $depreciations->skip($offset)->take($limit)->get(); $depreciations = $depreciations->skip($offset)->take($limit)->get();
@@ -70,8 +48,9 @@ class DepreciationsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/ */
public function store(Request $request) : JsonResponse public function store(Request $request)
{ {
$this->authorize('create', Depreciation::class); $this->authorize('create', Depreciation::class);
$depreciation = new Depreciation; $depreciation = new Depreciation;
@@ -90,8 +69,9 @@ class DepreciationsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function show($id) : JsonResponse | array public function show($id)
{ {
$this->authorize('view', Depreciation::class); $this->authorize('view', Depreciation::class);
$depreciation = Depreciation::findOrFail($id); $depreciation = Depreciation::findOrFail($id);
@@ -106,8 +86,9 @@ class DepreciationsController extends Controller
* @since [v4.0] * @since [v4.0]
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function update(Request $request, $id) : JsonResponse public function update(Request $request, $id)
{ {
$this->authorize('update', Depreciation::class); $this->authorize('update', Depreciation::class);
$depreciation = Depreciation::findOrFail($id); $depreciation = Depreciation::findOrFail($id);
@@ -126,8 +107,9 @@ class DepreciationsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function destroy($id) : JsonResponse public function destroy($id)
{ {
$this->authorize('delete', Depreciation::class); $this->authorize('delete', Depreciation::class);
$depreciation = Depreciation::withCount('models as models_count')->findOrFail($id); $depreciation = Depreciation::withCount('models as models_count')->findOrFail($id);
+19 -31
View File
@@ -7,7 +7,7 @@ use App\Http\Controllers\Controller;
use App\Http\Transformers\GroupsTransformer; use App\Http\Transformers\GroupsTransformer;
use App\Models\Group; use App\Models\Group;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Auth;
class GroupsController extends Controller class GroupsController extends Controller
@@ -17,14 +17,16 @@ class GroupsController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @return \Illuminate\Http\Response
*/ */
public function index(Request $request) : JsonResponse | array public function index(Request $request)
{ {
$this->authorize('superadmin'); $this->authorize('superadmin');
$this->authorize('view', Group::class); $this->authorize('view', Group::class);
$allowed_columns = ['id', 'name', 'created_at', 'users_count'];
$groups = Group::select('id', 'name', 'permissions', 'notes', 'created_at', 'updated_at', 'created_by')->with('adminuser')->withCount('users as users_count'); $groups = Group::select('id', 'name', 'permissions', 'created_at', 'updated_at', 'created_by')->with('admin')->withCount('users as users_count');
if ($request->filled('search')) { if ($request->filled('search')) {
$groups = $groups->TextSearch($request->input('search')); $groups = $groups->TextSearch($request->input('search'));
@@ -34,29 +36,13 @@ class GroupsController extends Controller
$groups->where('name', '=', $request->input('name')); $groups->where('name', '=', $request->input('name'));
} }
// Make sure the offset and limit are actually integers and do not exceed system limits
$offset = ($request->input('offset') > $groups->count()) ? $groups->count() : app('api_offset_value'); $offset = ($request->input('offset') > $groups->count()) ? $groups->count() : app('api_offset_value');
$limit = app('api_limit_value'); $limit = app('api_limit_value');
$order = $request->input('order') === 'asc' ? 'asc' : 'desc'; $order = $request->input('order') === 'asc' ? 'asc' : 'desc';
$sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'created_at';
switch ($request->input('sort')) { $groups->orderBy($sort, $order);
case 'created_by':
$groups = $groups->OrderByCreatedBy($order);
break;
default:
// This array is what determines which fields should be allowed to be sorted on ON the table itself.
// These must match a column on the consumables table directly.
$allowed_columns = [
'id',
'name',
'created_at',
'users_count',
];
$sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'created_at';
$groups = $groups->orderBy($sort, $order);
break;
}
$total = $groups->count(); $total = $groups->count();
$groups = $groups->skip($offset)->take($limit)->get(); $groups = $groups->skip($offset)->take($limit)->get();
@@ -70,18 +56,18 @@ class GroupsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/ */
public function store(Request $request) : JsonResponse public function store(Request $request)
{ {
$this->authorize('superadmin'); $this->authorize('superadmin');
$group = new Group; $group = new Group;
// Get all the available permissions // Get all the available permissions
$permissions = json_encode(config('permissions')); $permissions = config('permissions');
$groupPermissions = Helper::selectedPermissionsArray($permissions, $permissions); $groupPermissions = Helper::selectedPermissionsArray($permissions, $permissions);
$group->name = $request->input('name'); $group->name = $request->input('name');
$group->created_by = auth()->id(); $group->created_by = Auth::user()->id;
$group->notes = $request->input('notes');
$group->permissions = json_encode($request->input('permissions', $groupPermissions)); $group->permissions = json_encode($request->input('permissions', $groupPermissions));
if ($group->save()) { if ($group->save()) {
@@ -97,8 +83,9 @@ class GroupsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function show($id) : array public function show($id)
{ {
$this->authorize('superadmin'); $this->authorize('superadmin');
$group = Group::findOrFail($id); $group = Group::findOrFail($id);
@@ -112,14 +99,14 @@ class GroupsController extends Controller
* @since [v4.0] * @since [v4.0]
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function update(Request $request, $id) : JsonResponse public function update(Request $request, $id)
{ {
$this->authorize('superadmin'); $this->authorize('superadmin');
$group = Group::findOrFail($id); $group = Group::findOrFail($id);
$group->name = $request->input('name'); $group->name = $request->input('name');
$group->notes = $request->input('notes');
$group->permissions = $request->input('permissions'); // Todo - some JSON validation stuff here $group->permissions = $request->input('permissions'); // Todo - some JSON validation stuff here
if ($group->save()) { if ($group->save()) {
@@ -135,8 +122,9 @@ class GroupsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function destroy($id) : JsonResponse public function destroy($id)
{ {
$this->authorize('superadmin'); $this->authorize('superadmin');
$group = Group::findOrFail($id); $group = Group::findOrFail($id);
+15 -62
View File
@@ -9,28 +9,27 @@ use App\Http\Transformers\ImportsTransformer;
use App\Models\Asset; use App\Models\Asset;
use App\Models\Company; use App\Models\Company;
use App\Models\Import; use App\Models\Import;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Database\Eloquent\JsonEncodingException; use Illuminate\Database\Eloquent\JsonEncodingException;
use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use League\Csv\Reader; use League\Csv\Reader;
use Onnov\DetectEncoding\EncodingDetector;
use Symfony\Component\HttpFoundation\File\Exception\FileException; use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Http\JsonResponse;
class ImportController extends Controller class ImportController extends Controller
{ {
/** /**
* Display a listing of the resource. * Display a listing of the resource.
* *
* @return \Illuminate\Http\Response
*/ */
public function index() : JsonResponse | array public function index()
{ {
$this->authorize('import'); $this->authorize('import');
$imports = Import::with('adminuser')->latest()->get(); $imports = Import::latest()->get();
return (new ImportsTransformer)->transformImports($imports); return (new ImportsTransformer)->transformImports($imports);
} }
@@ -38,8 +37,9 @@ class ImportController extends Controller
* Process and store a CSV upload file. * Process and store a CSV upload file.
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/ */
public function store() : JsonResponse public function store()
{ {
$this->authorize('import'); $this->authorize('import');
if (! config('app.lock_passwords')) { if (! config('app.lock_passwords')) {
@@ -47,8 +47,6 @@ class ImportController extends Controller
$path = config('app.private_uploads').'/imports'; $path = config('app.private_uploads').'/imports';
$results = []; $results = [];
$import = new Import; $import = new Import;
$detector = new EncodingDetector();
foreach ($files as $file) { foreach ($files as $file) {
if (! in_array($file->getMimeType(), [ if (! in_array($file->getMimeType(), [
'application/vnd.ms-excel', 'application/vnd.ms-excel',
@@ -59,6 +57,7 @@ class ImportController extends Controller
'text/comma-separated-values', 'text/comma-separated-values',
'text/tsv', ])) { 'text/tsv', ])) {
$results['error'] = 'File type must be CSV. Uploaded file is '.$file->getMimeType(); $results['error'] = 'File type must be CSV. Uploaded file is '.$file->getMimeType();
return response()->json(Helper::formatStandardApiResponse('error', null, $results['error']), 422); return response()->json(Helper::formatStandardApiResponse('error', null, $results['error']), 422);
} }
@@ -66,44 +65,10 @@ class ImportController extends Controller
if (! ini_get('auto_detect_line_endings')) { if (! ini_get('auto_detect_line_endings')) {
ini_set('auto_detect_line_endings', '1'); ini_set('auto_detect_line_endings', '1');
} }
if (function_exists('iconv')) {
$file_contents = $file->getContent(); //TODO - this *does* load the whole file in RAM, but we need that to be able to 'iconv' it?
$encoding = $detector->getEncoding($file_contents);
\Log::debug("Discovered encoding: $encoding in uploaded CSV");
$reader = null;
if (strcasecmp($encoding, 'UTF-8') != 0) {
$transliterated = false;
try {
$transliterated = iconv(strtoupper($encoding), 'UTF-8', $file_contents);
} catch (\Exception $e) {
$transliterated = false; //blank out the partially-decoded string
return response()->json(
Helper::formatStandardApiResponse(
'error',
null,
trans('admin/hardware/message.import.transliterate_failure', ["encoding" => $encoding])
),
422
);
}
if ($transliterated !== false) {
$tmpname = tempnam(sys_get_temp_dir(), '');
$tmpresults = file_put_contents($tmpname, $transliterated);
$transliterated = null; //save on memory?
if ($tmpresults !== false) {
$newfile = new UploadedFile($tmpname, $file->getClientOriginalName(), null, null, true); //WARNING: this is enabling 'test mode' - which is gross, but otherwise the file won't be treated as 'uploaded'
if ($newfile->isValid()) {
$file = $newfile;
}
}
}
}
$file_contents = null; //try to save on memory, I guess?
}
$reader = Reader::createFromFileObject($file->openFile('r')); //file pointer leak? $reader = Reader::createFromFileObject($file->openFile('r')); //file pointer leak?
try { try {
$import->header_row = $reader->nth(0); $import->header_row = $reader->fetchOne(0);
} catch (JsonEncodingException $e) { } catch (JsonEncodingException $e) {
return response()->json( return response()->json(
Helper::formatStandardApiResponse( Helper::formatStandardApiResponse(
@@ -136,7 +101,7 @@ class ImportController extends Controller
try { try {
// Grab the first row to display via ajax as the user picks fields // Grab the first row to display via ajax as the user picks fields
$import->first_row = $reader->nth(1); $import->first_row = $reader->fetchOne(1);
} catch (JsonEncodingException $e) { } catch (JsonEncodingException $e) {
return response()->json( return response()->json(
Helper::formatStandardApiResponse( Helper::formatStandardApiResponse(
@@ -169,7 +134,7 @@ class ImportController extends Controller
} }
$import->filesize = filesize($path.'/'.$file_name); $import->filesize = filesize($path.'/'.$file_name);
$import->created_by = auth()->id();
$import->save(); $import->save();
$results[] = $import; $results[] = $import;
} }
@@ -187,15 +152,16 @@ class ImportController extends Controller
* Processes the specified Import. * Processes the specified Import.
* *
* @param int $import_id * @param int $import_id
* @return \Illuminate\Http\Response
*/ */
public function process(ItemImportRequest $request, $import_id) : JsonResponse public function process(ItemImportRequest $request, $import_id)
{ {
$this->authorize('import'); $this->authorize('import');
// Run a backup immediately before processing // Run a backup immediately before processing
if ($request->get('run-backup')) { if ($request->get('run-backup')) {
Log::debug('Backup manually requested via importer'); Log::debug('Backup manually requested via importer');
Artisan::call('snipeit:backup', ['--filename' => 'pre-import-backup-'.date('Y-m-d-H-i-s')]); Artisan::call('snipeit:backup', ['--filename' => 'pre-import-backup-'.date('Y-m-d-H:i:s')]);
} else { } else {
Log::debug('NO BACKUP requested via importer'); Log::debug('NO BACKUP requested via importer');
} }
@@ -213,9 +179,6 @@ class ImportController extends Controller
case 'asset': case 'asset':
$redirectTo = 'hardware.index'; $redirectTo = 'hardware.index';
break; break;
case 'assetModel':
$redirectTo = 'models.index';
break;
case 'accessory': case 'accessory':
$redirectTo = 'accessories.index'; $redirectTo = 'accessories.index';
break; break;
@@ -234,15 +197,6 @@ class ImportController extends Controller
case 'location': case 'location':
$redirectTo = 'locations.index'; $redirectTo = 'locations.index';
break; break;
case 'supplier':
$redirectTo = 'suppliers.index';
break;
case 'manufacturer':
$redirectTo = 'manufacturers.index';
break;
case 'category':
$redirectTo = 'categories.index';
break;
} }
if ($errors) { //Failure if ($errors) { //Failure
@@ -258,8 +212,9 @@ class ImportController extends Controller
* Remove the specified resource from storage. * Remove the specified resource from storage.
* *
* @param int $import_id * @param int $import_id
* @return \Illuminate\Http\Response
*/ */
public function destroy($import_id) : JsonResponse public function destroy($import_id)
{ {
$this->authorize('create', Asset::class); $this->authorize('create', Asset::class);
@@ -276,8 +231,6 @@ class ImportController extends Controller
return response()->json(Helper::formatStandardApiResponse('warning', null, trans('admin/hardware/message.import.file_not_deleted_warning'))); return response()->json(Helper::formatStandardApiResponse('warning', null, trans('admin/hardware/message.import.file_not_deleted_warning')));
} }
} }
return response()->json(Helper::formatStandardApiResponse('warning', null, trans('admin/hardware/message.import.file_not_deleted_warning')));
} }
} }
@@ -8,7 +8,7 @@ use App\Http\Transformers\LabelsTransformer;
use App\Models\Labels\Label; use App\Models\Labels\Label;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\ItemNotFoundException; use Illuminate\Support\ItemNotFoundException;
use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Auth;
class LabelsController extends Controller class LabelsController extends Controller
{ {
@@ -16,8 +16,9 @@ class LabelsController extends Controller
* Returns JSON listing of all labels. * Returns JSON listing of all labels.
* *
* @author Grant Le Roux <grant.leroux+snipe-it@gmail.com> * @author Grant Le Roux <grant.leroux+snipe-it@gmail.com>
* @return JsonResponse
*/ */
public function index(Request $request) : JsonResponse | array public function index(Request $request)
{ {
$this->authorize('view', Label::class); $this->authorize('view', Label::class);
@@ -49,8 +50,9 @@ class LabelsController extends Controller
* *
* @author Grant Le Roux <grant.leroux+snipe-it@gmail.com> * @author Grant Le Roux <grant.leroux+snipe-it@gmail.com>
* @param string $labelName * @param string $labelName
* @return JsonResponse
*/ */
public function show(string $labelName) : JsonResponse | array public function show(string $labelName)
{ {
$labelName = str_replace('/', '\\', $labelName); $labelName = str_replace('/', '\\', $labelName);
try { try {
@@ -9,7 +9,7 @@ use App\Models\Asset;
use App\Models\License; use App\Models\License;
use App\Models\LicenseSeat; use App\Models\LicenseSeat;
use App\Models\User; use App\Models\User;
use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class LicenseSeatsController extends Controller class LicenseSeatsController extends Controller
@@ -19,31 +19,23 @@ class LicenseSeatsController extends Controller
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param int $licenseId * @param int $licenseId
* @return \Illuminate\Http\Response
*/ */
public function index(Request $request, $licenseId) : JsonResponse | array public function index(Request $request, $licenseId)
{ {
//
if ($license = License::find($licenseId)) { if ($license = License::find($licenseId)) {
$this->authorize('view', $license); $this->authorize('view', $license);
$seats = LicenseSeat::with('license', 'user', 'asset', 'user.department') $seats = LicenseSeat::with('license', 'user', 'asset', 'user.department')
->where('license_seats.license_id', $licenseId); ->where('license_seats.license_id', $licenseId);
if ($request->input('status') == 'available') {
$seats->whereNull('license_seats.assigned_to');
}
if ($request->input('status') == 'assigned') {
$seats->ByAssigned();
}
$order = $request->input('order') === 'asc' ? 'asc' : 'desc'; $order = $request->input('order') === 'asc' ? 'asc' : 'desc';
if ($request->input('sort') == 'department') { if ($request->input('sort') == 'department') {
$seats->OrderDepartments($order); $seats->OrderDepartments($order);
} else { } else {
$seats->orderBy('updated_at', $order); $seats->orderBy('id', $order);
} }
$total = $seats->count(); $total = $seats->count();
@@ -72,10 +64,11 @@ class LicenseSeatsController extends Controller
* *
* @param int $licenseId * @param int $licenseId
* @param int $seatId * @param int $seatId
* @return \Illuminate\Http\Response
*/ */
public function show($licenseId, $seatId) : JsonResponse | array public function show($licenseId, $seatId)
{ {
//
$this->authorize('view', License::class); $this->authorize('view', License::class);
// sanity checks: // sanity checks:
// 1. does the license seat exist? // 1. does the license seat exist?
@@ -96,18 +89,19 @@ class LicenseSeatsController extends Controller
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param int $licenseId * @param int $licenseId
* @param int $seatId * @param int $seatId
* @return \Illuminate\Http\Response
*/ */
public function update(Request $request, $licenseId, $seatId) : JsonResponse | array public function update(Request $request, $licenseId, $seatId)
{ {
$this->authorize('checkout', License::class); $this->authorize('checkout', License::class);
// sanity checks:
// 1. does the license seat exist?
if (! $licenseSeat = LicenseSeat::find($seatId)) { if (! $licenseSeat = LicenseSeat::find($seatId)) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat not found')); return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat not found'));
} }
// 2. does the seat belong to the specified license?
$license = $licenseSeat->license()->first(); if (! $license = $licenseSeat->license()->first() || $license->id != intval($licenseId)) {
if (!$license || $license->id != intval($licenseId)) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license')); return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
} }
@@ -116,7 +110,7 @@ class LicenseSeatsController extends Controller
// attempt to update the license seat // attempt to update the license seat
$licenseSeat->fill($request->all()); $licenseSeat->fill($request->all());
$licenseSeat->created_by = auth()->id(); $licenseSeat->user_id = Auth::user()->id;
// check if this update is a checkin operation // check if this update is a checkin operation
// 1. are relevant fields touched at all? // 1. are relevant fields touched at all?
@@ -128,9 +122,7 @@ class LicenseSeatsController extends Controller
// nothing to update // nothing to update
return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success'))); return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
} }
if( $touched && $licenseSeat->unreassignable_seat) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.unavailable')));
}
// the logging functions expect only one "target". if both asset and user are present in the request, // the logging functions expect only one "target". if both asset and user are present in the request,
// we simply let assets take precedence over users... // we simply let assets take precedence over users...
if ($licenseSeat->isDirty('assigned_to')) { if ($licenseSeat->isDirty('assigned_to')) {
@@ -147,17 +139,13 @@ class LicenseSeatsController extends Controller
if ($licenseSeat->save()) { if ($licenseSeat->save()) {
if ($is_checkin) { if ($is_checkin) {
if(!$licenseSeat->license->reassignable){ $licenseSeat->logCheckin($target, $request->input('note'));
$licenseSeat->unreassignable_seat = true;
$licenseSeat->save();
}
$licenseSeat->logCheckin($target, $licenseSeat->notes);
return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success'))); return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
} }
// in this case, relevant fields are touched but it's not a checkin operation. so it must be a checkout operation. // in this case, relevant fields are touched but it's not a checkin operation. so it must be a checkout operation.
$licenseSeat->logCheckout($request->input('notes'), $target); $licenseSeat->logCheckout($request->input('note'), $target);
return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success'))); return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
} }
+19 -16
View File
@@ -4,12 +4,14 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper; use App\Helpers\Helper;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Transformers\LicenseSeatsTransformer;
use App\Http\Transformers\LicensesTransformer; use App\Http\Transformers\LicensesTransformer;
use App\Http\Transformers\SelectlistTransformer; use App\Http\Transformers\SelectlistTransformer;
use App\Models\Company;
use App\Models\License; use App\Models\License;
use App\Models\LicenseSeat;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Http\JsonResponse;
class LicensesController extends Controller class LicensesController extends Controller
{ {
@@ -19,15 +21,16 @@ class LicensesController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* *
* @return \Illuminate\Http\Response
*/ */
public function index(Request $request) : JsonResponse | array public function index(Request $request)
{ {
$this->authorize('view', License::class); $this->authorize('view', License::class);
$licenses = License::with('company', 'manufacturer', 'supplier','category', 'adminuser')->withCount('freeSeats as free_seats_count'); $licenses = License::with('company', 'manufacturer', 'supplier','category')->withCount('freeSeats as free_seats_count');
if ($request->filled('company_id')) { if ($request->filled('company_id')) {
$licenses->where('licenses.company_id', '=', $request->input('company_id')); $licenses->where('company_id', '=', $request->input('company_id'));
} }
if ($request->filled('name')) { if ($request->filled('name')) {
@@ -70,9 +73,6 @@ class LicensesController extends Controller
$licenses->where('depreciation_id', '=', $request->input('depreciation_id')); $licenses->where('depreciation_id', '=', $request->input('depreciation_id'));
} }
if ($request->filled('created_by')) {
$licenses->where('created_by', '=', $request->input('created_by'));
}
if (($request->filled('maintained')) && ($request->input('maintained')=='true')) { if (($request->filled('maintained')) && ($request->input('maintained')=='true')) {
$licenses->where('maintained','=',1); $licenses->where('maintained','=',1);
@@ -116,9 +116,6 @@ class LicensesController extends Controller
case 'company': case 'company':
$licenses = $licenses->leftJoin('companies', 'licenses.company_id', '=', 'companies.id')->orderBy('companies.name', $order); $licenses = $licenses->leftJoin('companies', 'licenses.company_id', '=', 'companies.id')->orderBy('companies.name', $order);
break; break;
case 'created_by':
$licenses = $licenses->OrderByCreatedBy($order);
break;
default: default:
$allowed_columns = $allowed_columns =
[ [
@@ -159,9 +156,11 @@ class LicensesController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/ */
public function store(Request $request) : JsonResponse public function store(Request $request)
{ {
//
$this->authorize('create', License::class); $this->authorize('create', License::class);
$license = new License; $license = new License;
$license->fill($request->all()); $license->fill($request->all());
@@ -178,11 +177,12 @@ class LicensesController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function show($id) : JsonResponse | array public function show($id)
{ {
$this->authorize('view', License::class); $this->authorize('view', License::class);
$license = License::withCount('freeSeats as free_seats_count')->findOrFail($id); $license = License::withCount('freeSeats')->findOrFail($id);
$license = $license->load('assignedusers', 'licenseSeats.user', 'licenseSeats.asset'); $license = $license->load('assignedusers', 'licenseSeats.user', 'licenseSeats.asset');
return (new LicensesTransformer)->transformLicense($license); return (new LicensesTransformer)->transformLicense($license);
@@ -195,8 +195,9 @@ class LicensesController extends Controller
* @since [v4.0] * @since [v4.0]
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function update(Request $request, $id) : JsonResponse | array public function update(Request $request, $id)
{ {
// //
$this->authorize('update', License::class); $this->authorize('update', License::class);
@@ -217,9 +218,11 @@ class LicensesController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function destroy($id) : JsonResponse public function destroy($id)
{ {
//
$license = License::findOrFail($id); $license = License::findOrFail($id);
$this->authorize('delete', $license); $this->authorize('delete', $license);
@@ -245,7 +248,7 @@ class LicensesController extends Controller
* *
* @see \App\Http\Transformers\SelectlistTransformer * @see \App\Http\Transformers\SelectlistTransformer
*/ */
public function selectlist(Request $request) : array public function selectlist(Request $request)
{ {
$licenses = License::select([ $licenses = License::select([
'licenses.id', 'licenses.id',
+30 -119
View File
@@ -3,19 +3,11 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Helpers\Helper; use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\ImageUploadRequest; use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\AccessoriesTransformer; use App\Http\Controllers\Controller;
use App\Http\Transformers\AssetsTransformer;
use App\Http\Transformers\LocationsTransformer; use App\Http\Transformers\LocationsTransformer;
use App\Http\Transformers\SelectlistTransformer; use App\Http\Transformers\SelectlistTransformer;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Location; use App\Models\Location;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -29,34 +21,30 @@ class LocationsController extends Controller
* @since [v4.0] * @since [v4.0]
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*/ */
public function index(Request $request) : JsonResponse | array public function index(Request $request)
{ {
$this->authorize('view', Location::class); $this->authorize('view', Location::class);
$allowed_columns = [ $allowed_columns = [
'accessories_count', 'id',
'name',
'address', 'address',
'address2', 'address2',
'assets_count',
'assets_count',
'assigned_accessories_count',
'assigned_assets_count',
'assigned_assets_count',
'city', 'city',
'country',
'created_at',
'currency',
'id',
'image',
'ldap_ou',
'company_id',
'manager_id',
'name',
'rtd_assets_count',
'state', 'state',
'updated_at', 'country',
'users_count',
'zip', 'zip',
'notes', 'created_at',
'updated_at',
'manager_id',
'image',
'assigned_assets_count',
'users_count',
'assets_count',
'assigned_assets_count',
'assets_count',
'rtd_assets_count',
'currency',
'ldap_ou',
]; ];
$locations = Location::with('parent', 'manager', 'children')->select([ $locations = Location::with('parent', 'manager', 'children')->select([
@@ -77,23 +65,11 @@ class LocationsController extends Controller
'locations.image', 'locations.image',
'locations.ldap_ou', 'locations.ldap_ou',
'locations.currency', 'locations.currency',
'locations.company_id', ])->withCount('assignedAssets as assigned_assets_count')
'locations.notes',
])
->withCount('assignedAssets as assigned_assets_count')
->withCount('assignedAssets as assigned_assets_count')
->withCount('assets as assets_count') ->withCount('assets as assets_count')
->withCount('assignedAccessories as assigned_accessories_count')
->withCount('accessories as accessories_count')
->withCount('rtd_assets as rtd_assets_count') ->withCount('rtd_assets as rtd_assets_count')
->withCount('children as children_count') ->withCount('children as children_count')
->withCount('users as users_count') ->withCount('users as users_count');
->with('adminuser');
// Only scope locations if the setting is enabled
if (Setting::getSettings()->scope_locations_fmcs) {
$locations = Company::scopeCompanyables($locations);
}
if ($request->filled('search')) { if ($request->filled('search')) {
$locations = $locations->TextSearch($request->input('search')); $locations = $locations->TextSearch($request->input('search'));
@@ -127,10 +103,6 @@ class LocationsController extends Controller
$locations->where('locations.manager_id', '=', $request->input('manager_id')); $locations->where('locations.manager_id', '=', $request->input('manager_id'));
} }
if ($request->filled('company_id')) {
$locations->where('locations.company_id', '=', $request->input('company_id'));
}
// Make sure the offset and limit are actually integers and do not exceed system limits // Make sure the offset and limit are actually integers and do not exceed system limits
$offset = ($request->input('offset') > $locations->count()) ? $locations->count() : app('api_offset_value'); $offset = ($request->input('offset') > $locations->count()) ? $locations->count() : app('api_offset_value');
$limit = app('api_limit_value'); $limit = app('api_limit_value');
@@ -147,9 +119,6 @@ class LocationsController extends Controller
case 'manager': case 'manager':
$locations->OrderManager($order); $locations->OrderManager($order);
break; break;
case 'company':
$locations->OrderCompany($order);
break;
default: default:
$locations->orderBy($sort, $order); $locations->orderBy($sort, $order);
break; break;
@@ -169,23 +138,15 @@ class LocationsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param \App\Http\Requests\ImageUploadRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @return \Illuminate\Http\Response
*/ */
public function store(ImageUploadRequest $request) : JsonResponse public function store(ImageUploadRequest $request)
{ {
$this->authorize('create', Location::class); $this->authorize('create', Location::class);
$location = new Location; $location = new Location;
$location->fill($request->all()); $location->fill($request->all());
$location = $request->handleImages($location); $location = $request->handleImages($location);
// Only scope location if the setting is enabled
if (Setting::getSettings()->scope_locations_fmcs) {
$location->company_id = Company::getIdForCurrentUser($request->get('company_id'));
// check if parent is set and has a different company
if ($location->parent_id && Location::find($location->parent_id)->company_id != $location->company_id) {
response()->json(Helper::formatStandardApiResponse('error', null, 'different company than parent'));
}
}
if ($location->save()) { if ($location->save()) {
return response()->json(Helper::formatStandardApiResponse('success', (new LocationsTransformer)->transformLocation($location), trans('admin/locations/message.create.success'))); return response()->json(Helper::formatStandardApiResponse('success', (new LocationsTransformer)->transformLocation($location), trans('admin/locations/message.create.success')));
} }
@@ -199,11 +160,12 @@ class LocationsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function show($id) : JsonResponse | array public function show($id)
{ {
$this->authorize('view', Location::class); $this->authorize('view', Location::class);
$location = Location::with('parent', 'manager', 'children', 'company') $location = Location::with('parent', 'manager', 'children')
->select([ ->select([
'locations.id', 'locations.id',
'locations.name', 'locations.name',
@@ -219,8 +181,6 @@ class LocationsController extends Controller
'locations.updated_at', 'locations.updated_at',
'locations.image', 'locations.image',
'locations.currency', 'locations.currency',
'locations.company_id',
'locations.notes',
]) ])
->withCount('assignedAssets as assigned_assets_count') ->withCount('assignedAssets as assigned_assets_count')
->withCount('assets as assets_count') ->withCount('assets as assets_count')
@@ -239,8 +199,9 @@ class LocationsController extends Controller
* @since [v4.0] * @since [v4.0]
* @param \App\Http\Requests\ImageUploadRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @param int $id * @param int $id
* @return \Illuminate\Http\JsonResponse
*/ */
public function update(ImageUploadRequest $request, $id) : JsonResponse public function update(ImageUploadRequest $request, $id)
{ {
$this->authorize('update', Location::class); $this->authorize('update', Location::class);
$location = Location::findOrFail($id); $location = Location::findOrFail($id);
@@ -248,19 +209,6 @@ class LocationsController extends Controller
$location->fill($request->all()); $location->fill($request->all());
$location = $request->handleImages($location); $location = $request->handleImages($location);
if ($request->filled('company_id')) {
// Only scope location if the setting is enabled
if (Setting::getSettings()->scope_locations_fmcs) {
$location->company_id = Company::getIdForCurrentUser($request->get('company_id'));
// check if there are related objects with different company
if (Helper::test_locations_fmcs(false, $id, $location->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'error scoped locations'));
}
} else {
$location->company_id = $request->get('company_id');
}
}
if ($location->isValid()) { if ($location->isValid()) {
$location->save(); $location->save();
@@ -276,47 +224,15 @@ class LocationsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, $location->getErrors())); return response()->json(Helper::formatStandardApiResponse('error', null, $location->getErrors()));
} }
public function assets(Request $request, Location $location) : JsonResponse | array
{
$this->authorize('view', Asset::class);
$this->authorize('view', $location);
$assets = Asset::where('location_id', '=', $location->id)->with('model', 'model.category', 'assetstatus', 'location', 'company', 'defaultLoc');
$assets = $assets->get();
return (new AssetsTransformer)->transformAssets($assets, $assets->count(), $request);
}
public function assignedAssets(Request $request, Location $location) : JsonResponse | array
{
$this->authorize('view', Asset::class);
$this->authorize('view', $location);
$assets = Asset::where('assigned_to', '=', $location->id)->where('assigned_type', '=', Location::class)->with('model', 'model.category', 'assetstatus', 'location', 'company', 'defaultLoc');
$assets = $assets->get();
return (new AssetsTransformer)->transformAssets($assets, $assets->count(), $request);
}
public function assignedAccessories(Request $request, Location $location) : JsonResponse | array
{
$this->authorize('view', Accessory::class);
$this->authorize('view', $location);
$accessory_checkouts = AccessoryCheckout::LocationAssigned()->where('assigned_to', $location->id)->with('adminuser')->with('accessories');
$offset = ($request->input('offset') > $accessory_checkouts->count()) ? $accessory_checkouts->count() : app('api_offset_value');
$limit = app('api_limit_value');
$total = $accessory_checkouts->count();
$accessory_checkouts = $accessory_checkouts->skip($offset)->take($limit)->get();
return (new LocationsTransformer)->transformCheckedoutAccessories($accessory_checkouts, $total);
}
/** /**
* Remove the specified resource from storage. * Remove the specified resource from storage.
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function destroy($id) : JsonResponse public function destroy($id)
{ {
$this->authorize('delete', Location::class); $this->authorize('delete', Location::class);
$location = Location::withCount('assignedAssets as assigned_assets_count') $location = Location::withCount('assignedAssets as assigned_assets_count')
@@ -324,7 +240,6 @@ class LocationsController extends Controller
->withCount('rtd_assets as rtd_assets_count') ->withCount('rtd_assets as rtd_assets_count')
->withCount('children as children_count') ->withCount('children as children_count')
->withCount('users as users_count') ->withCount('users as users_count')
->withCount('accessories as accessories_count')
->findOrFail($id); ->findOrFail($id);
if (! $location->isDeletable()) { if (! $location->isDeletable()) {
@@ -365,7 +280,7 @@ class LocationsController extends Controller
* @since [v4.0.16] * @since [v4.0.16]
* @see \App\Http\Transformers\SelectlistTransformer * @see \App\Http\Transformers\SelectlistTransformer
*/ */
public function selectlist(Request $request) : array public function selectlist(Request $request)
{ {
// If a user is in the process of editing their profile, as determined by the referrer, // If a user is in the process of editing their profile, as determined by the referrer,
// then we check that they have permission to edit their own location. // then we check that they have permission to edit their own location.
@@ -381,11 +296,6 @@ class LocationsController extends Controller
'locations.image', 'locations.image',
]); ]);
// Only scope locations if the setting is enabled
if (Setting::getSettings()->scope_locations_fmcs) {
$locations = Company::scopeCompanyables($locations);
}
$page = 1; $page = 1;
if ($request->filled('page')) { if ($request->filled('page')) {
$page = $request->input('page'); $page = $request->input('page');
@@ -415,6 +325,7 @@ class LocationsController extends Controller
$paginated_results = new LengthAwarePaginator($locations_formatted->forPage($page, 500), $locations_formatted->count(), 500, $page, []); $paginated_results = new LengthAwarePaginator($locations_formatted->forPage($page, 500), $locations_formatted->count(), 500, $page, []);
//return [];
return (new SelectlistTransformer)->transformSelectlist($paginated_results); return (new SelectlistTransformer)->transformSelectlist($paginated_results);
} }
} }
@@ -10,8 +10,8 @@ use App\Models\Actionlog;
use App\Models\Manufacturer; use App\Models\Manufacturer;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Requests\ImageUploadRequest; use App\Http\Requests\ImageUploadRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Http\JsonResponse;
class ManufacturersController extends Controller class ManufacturersController extends Controller
{ {
@@ -22,48 +22,14 @@ class ManufacturersController extends Controller
* @since [v4.0] * @since [v4.0]
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*/ */
public function index(Request $request) : JsonResponse | array public function index(Request $request)
{ {
$this->authorize('view', Manufacturer::class); $this->authorize('view', Manufacturer::class);
$allowed_columns = [ $allowed_columns = ['id', 'name', 'url', 'support_url', 'support_email', 'warranty_lookup_url', 'support_phone', 'created_at', 'updated_at', 'image', 'assets_count', 'consumables_count', 'components_count', 'licenses_count'];
'id',
'name',
'url',
'support_url',
'support_email',
'warranty_lookup_url',
'support_phone',
'created_at',
'updated_at',
'image',
'assets_count',
'consumables_count',
'components_count',
'licenses_count',
'notes',
];
$manufacturers = Manufacturer::select([ $manufacturers = Manufacturer::select(
'id', ['id', 'name', 'url', 'support_url', 'warranty_lookup_url', 'support_email', 'support_phone', 'created_at', 'updated_at', 'image', 'deleted_at']
'name', )->withCount('assets as assets_count')->withCount('licenses as licenses_count')->withCount('consumables as consumables_count')->withCount('accessories as accessories_count');
'url',
'support_url',
'warranty_lookup_url',
'support_email',
'support_phone',
'created_by',
'created_at',
'updated_at',
'image',
'deleted_at',
'notes',
])
->with('adminuser')
->withCount('assets as assets_count')
->withCount('licenses as licenses_count')
->withCount('consumables as consumables_count')
->withCount('accessories as accessories_count')
->withCount('components as components_count');
if ($request->input('deleted') == 'true') { if ($request->input('deleted') == 'true') {
$manufacturers->onlyTrashed(); $manufacturers->onlyTrashed();
@@ -100,18 +66,10 @@ class ManufacturersController extends Controller
// Make sure the offset and limit are actually integers and do not exceed system limits // Make sure the offset and limit are actually integers and do not exceed system limits
$offset = ($request->input('offset') > $manufacturers->count()) ? $manufacturers->count() : app('api_offset_value'); $offset = ($request->input('offset') > $manufacturers->count()) ? $manufacturers->count() : app('api_offset_value');
$limit = app('api_limit_value'); $limit = app('api_limit_value');
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
$sort_override = $request->input('sort');
$column_sort = in_array($sort_override, $allowed_columns) ? $sort_override : 'created_at';
switch ($sort_override) { $order = $request->input('order') === 'asc' ? 'asc' : 'desc';
case 'created_by': $sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'created_at';
$manufacturers = $manufacturers->OrderByCreatedBy($order); $manufacturers->orderBy($sort, $order);
break;
default:
$manufacturers = $manufacturers->orderBy($column_sort, $order);
break;
}
$total = $manufacturers->count(); $total = $manufacturers->count();
$manufacturers = $manufacturers->skip($offset)->take($limit)->get(); $manufacturers = $manufacturers->skip($offset)->take($limit)->get();
@@ -125,8 +83,9 @@ class ManufacturersController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param \App\Http\Requests\ImageUploadRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @return \Illuminate\Http\Response
*/ */
public function store(ImageUploadRequest $request) : JsonResponse public function store(ImageUploadRequest $request)
{ {
$this->authorize('create', Manufacturer::class); $this->authorize('create', Manufacturer::class);
$manufacturer = new Manufacturer; $manufacturer = new Manufacturer;
@@ -146,8 +105,9 @@ class ManufacturersController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function show($id) : JsonResponse | array public function show($id)
{ {
$this->authorize('view', Manufacturer::class); $this->authorize('view', Manufacturer::class);
$manufacturer = Manufacturer::withCount('assets as assets_count')->withCount('licenses as licenses_count')->withCount('consumables as consumables_count')->withCount('accessories as accessories_count')->findOrFail($id); $manufacturer = Manufacturer::withCount('assets as assets_count')->withCount('licenses as licenses_count')->withCount('consumables as consumables_count')->withCount('accessories as accessories_count')->findOrFail($id);
@@ -162,8 +122,9 @@ class ManufacturersController extends Controller
* @since [v4.0] * @since [v4.0]
* @param \App\Http\Requests\ImageUploadRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function update(ImageUploadRequest $request, $id) : JsonResponse public function update(ImageUploadRequest $request, $id)
{ {
$this->authorize('update', Manufacturer::class); $this->authorize('update', Manufacturer::class);
$manufacturer = Manufacturer::findOrFail($id); $manufacturer = Manufacturer::findOrFail($id);
@@ -183,8 +144,9 @@ class ManufacturersController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function destroy($id) : JsonResponse public function destroy($id)
{ {
$this->authorize('delete', Manufacturer::class); $this->authorize('delete', Manufacturer::class);
$manufacturer = Manufacturer::findOrFail($id); $manufacturer = Manufacturer::findOrFail($id);
@@ -205,9 +167,10 @@ class ManufacturersController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v6.3.4] * @since [v6.3.4]
* @param int $id * @param int $id
* @return \Illuminate\Http\JsonResponse
* @throws \Illuminate\Auth\Access\AuthorizationException * @throws \Illuminate\Auth\Access\AuthorizationException
*/ */
public function restore($id) : JsonResponse public function restore($id)
{ {
$this->authorize('delete', Manufacturer::class); $this->authorize('delete', Manufacturer::class);
@@ -223,7 +186,7 @@ class ManufacturersController extends Controller
$logaction->item_type = Manufacturer::class; $logaction->item_type = Manufacturer::class;
$logaction->item_id = $manufacturer->id; $logaction->item_id = $manufacturer->id;
$logaction->created_at = date('Y-m-d H:i:s'); $logaction->created_at = date('Y-m-d H:i:s');
$logaction->created_by = auth()->id(); $logaction->user_id = Auth::user()->id;
$logaction->logaction('restore'); $logaction->logaction('restore');
return response()->json(Helper::formatStandardApiResponse('success', trans('admin/manufacturers/message.restore.success')), 200); return response()->json(Helper::formatStandardApiResponse('success', trans('admin/manufacturers/message.restore.success')), 200);
@@ -243,7 +206,7 @@ class ManufacturersController extends Controller
* @since [v4.0.16] * @since [v4.0.16]
* @see \App\Http\Transformers\SelectlistTransformer * @see \App\Http\Transformers\SelectlistTransformer
*/ */
public function selectlist(Request $request) : array public function selectlist(Request $request)
{ {
$this->authorize('view.selectlists'); $this->authorize('view.selectlists');
@@ -1,95 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Models\Actionlog;
use App\Models\Asset;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
/**
* This class controls all API actions related to notes for
* the Snipe-IT Asset Management application.
*/
class NotesController extends Controller
{
/**
* Retrieve a list of manual notes (action logs) for a given asset.
*
* Checks authorization to view assets, attempts to find the asset by ID,
* and fetches related action log entries of type 'note added', including
* user information for each note. Returns a JSON response with the notes or errors.
*
* @param \Illuminate\Http\Request $request The incoming HTTP request.
* @param Asset $asset The ID of the asset whose notes to retrieve.
* @return \Illuminate\Http\JsonResponse
*/
public function index(Asset $asset): JsonResponse
{
$this->authorize('view', $asset);
// Get the manual notes for the asset
$notes = ActionLog::with('user:id,username')
->where('item_type', Asset::class)
->where('item_id', $asset->id)
->where('action_type', 'note added')
->orderBy('created_at', 'desc')
->get(['id', 'created_at', 'note', 'created_by', 'item_id', 'item_type', 'action_type', 'target_id', 'target_type']);
$notesArray = $notes->map(function ($note) {
return [
'id' => $note->id,
'created_at' => $note->created_at,
'note' => $note->note,
'created_by' => $note->created_by,
'username' => $note->user?->username, // adding the username
'item_id' => $note->item_id,
'item_type' => $note->item_type,
'action_type' => $note->action_type,
];
});
// Return a success response
return response()->json(Helper::formatStandardApiResponse('success', ['notes' => $notesArray, 'asset_id' => $asset->id]));
}
/**
* Store a manual note on a specified asset and log the action.
*
* Checks authorization for updating assets, validates the presence of the 'note',
* attempts to find the asset by ID, and creates a new ActionLog entry if successful.
* Returns JSON responses indicating success or failure with appropriate HTTP status codes.
*
* @param \Illuminate\Http\Request $request The incoming HTTP request containing the 'note'.
* @param Asset $asset The ID of the asset to attach the note to.
* @return \Illuminate\Http\JsonResponse
*/
public function store(Request $request, Asset $asset): JsonResponse
{
$this->authorize('update', $asset);
if ($request->input('note', '') == '') {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('validation.required', ['attribute' => 'note'])), 422);
}
// Create the note
$logaction = new ActionLog();
$logaction->item_type = get_class($asset);
$logaction->created_by = Auth::id();
$logaction->item_id = $asset->id;
$logaction->note = $request->input('note', '');
if ($logaction->logaction('note added')) {
// Return a success response
return response()->json(Helper::formatStandardApiResponse('success', ['note' => $logaction->note, 'item_id' => $asset->id], trans('general.note_added')));
}
// Return an error response if something went wrong
return response()->json(Helper::formatStandardApiResponse('error', null, 'Something went wrong'), 500);
}
}
@@ -7,8 +7,6 @@ use App\Http\Controllers\Controller;
use App\Http\Transformers\PredefinedKitsTransformer; use App\Http\Transformers\PredefinedKitsTransformer;
use App\Models\PredefinedKit; use App\Models\PredefinedKit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Transformers\SelectlistTransformer;
/** /**
* @author [D. Minaev.] [<dmitriy.minaev.v@gmail.com>] * @author [D. Minaev.] [<dmitriy.minaev.v@gmail.com>]
@@ -20,11 +18,12 @@ class PredefinedKitsController extends Controller
* *
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*/ */
public function index(Request $request) : JsonResponse | array public function index(Request $request)
{ {
$this->authorize('view', PredefinedKit::class); $this->authorize('view', PredefinedKit::class);
$allowed_columns = ['id', 'name'];
$kits = PredefinedKit::query()->with('adminuser'); $kits = PredefinedKit::query();
if ($request->filled('search')) { if ($request->filled('search')) {
$kits = $kits->TextSearch($request->input('search')); $kits = $kits->TextSearch($request->input('search'));
@@ -35,25 +34,8 @@ class PredefinedKitsController extends Controller
$limit = app('api_limit_value'); $limit = app('api_limit_value');
$order = $request->input('order') === 'desc' ? 'desc' : 'asc'; $order = $request->input('order') === 'desc' ? 'desc' : 'asc';
$sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'name';
switch ($request->input('sort')) { $kits->orderBy($sort, $order);
case 'created_by':
$kits = $kits->OrderByCreatedBy($order);
break;
default:
// This array is what determines which fields should be allowed to be sorted on ON the table itself.
// These must match a column on the consumables table directly.
$allowed_columns = [
'id',
'name',
'created_at',
'updated_at',
];
$sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'created_at';
$kits = $kits->orderBy($sort, $order);
break;
}
$total = $kits->count(); $total = $kits->count();
$kits = $kits->skip($offset)->take($limit)->get(); $kits = $kits->skip($offset)->take($limit)->get();
@@ -65,8 +47,9 @@ class PredefinedKitsController extends Controller
* Store a newly created resource in storage. * Store a newly created resource in storage.
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/ */
public function store(Request $request) : JsonResponse public function store(Request $request)
{ {
$this->authorize('create', PredefinedKit::class); $this->authorize('create', PredefinedKit::class);
$kit = new PredefinedKit; $kit = new PredefinedKit;
@@ -83,8 +66,9 @@ class PredefinedKitsController extends Controller
* Display the specified resource. * Display the specified resource.
* *
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function show($id) : array public function show($id)
{ {
$this->authorize('view', PredefinedKit::class); $this->authorize('view', PredefinedKit::class);
$kit = PredefinedKit::findOrFail($id); $kit = PredefinedKit::findOrFail($id);
@@ -97,8 +81,9 @@ class PredefinedKitsController extends Controller
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param int $id kit id * @param int $id kit id
* @return \Illuminate\Http\Response
*/ */
public function update(Request $request, $id) : JsonResponse public function update(Request $request, $id)
{ {
$this->authorize('update', PredefinedKit::class); $this->authorize('update', PredefinedKit::class);
$kit = PredefinedKit::findOrFail($id); $kit = PredefinedKit::findOrFail($id);
@@ -115,8 +100,9 @@ class PredefinedKitsController extends Controller
* Remove the specified resource from storage. * Remove the specified resource from storage.
* *
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function destroy($id) : JsonResponse public function destroy($id)
{ {
$this->authorize('delete', PredefinedKit::class); $this->authorize('delete', PredefinedKit::class);
$kit = PredefinedKit::findOrFail($id); $kit = PredefinedKit::findOrFail($id);
@@ -137,7 +123,7 @@ class PredefinedKitsController extends Controller
* *
* @see \App\Http\Transformers\SelectlistTransformer * @see \App\Http\Transformers\SelectlistTransformer
*/ */
public function selectlist(Request $request) : array public function selectlist(Request $request)
{ {
$kits = PredefinedKit::select([ $kits = PredefinedKit::select([
'id', 'id',
@@ -159,7 +145,7 @@ class PredefinedKitsController extends Controller
* @param int $id * @param int $id
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*/ */
public function indexLicenses($kit_id) : array public function indexLicenses($kit_id)
{ {
$this->authorize('view', PredefinedKit::class); $this->authorize('view', PredefinedKit::class);
$kit = PredefinedKit::findOrFail($kit_id); $kit = PredefinedKit::findOrFail($kit_id);
@@ -174,7 +160,7 @@ class PredefinedKitsController extends Controller
* @param int $id * @param int $id
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*/ */
public function storeLicense(Request $request, $kit_id) : JsonResponse public function storeLicense(Request $request, $kit_id)
{ {
$this->authorize('update', PredefinedKit::class); $this->authorize('update', PredefinedKit::class);
@@ -200,8 +186,9 @@ class PredefinedKitsController extends Controller
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param int $kit_id * @param int $kit_id
* @return \Illuminate\Http\Response
*/ */
public function updateLicense(Request $request, $kit_id, $license_id) : JsonResponse public function updateLicense(Request $request, $kit_id, $license_id)
{ {
$this->authorize('update', PredefinedKit::class); $this->authorize('update', PredefinedKit::class);
$kit = PredefinedKit::findOrFail($kit_id); $kit = PredefinedKit::findOrFail($kit_id);
@@ -218,8 +205,9 @@ class PredefinedKitsController extends Controller
* Remove the specified resource from storage. * Remove the specified resource from storage.
* *
* @param int $kit_id * @param int $kit_id
* @return \Illuminate\Http\Response
*/ */
public function detachLicense($kit_id, $license_id) : JsonResponse public function detachLicense($kit_id, $license_id)
{ {
$this->authorize('update', PredefinedKit::class); $this->authorize('update', PredefinedKit::class);
$kit = PredefinedKit::findOrFail($kit_id); $kit = PredefinedKit::findOrFail($kit_id);
@@ -233,8 +221,9 @@ class PredefinedKitsController extends Controller
* Display the specified resource. * Display the specified resource.
* *
* @param int $kit_id * @param int $kit_id
* @return \Illuminate\Http\Response
*/ */
public function indexModels($kit_id) : array public function indexModels($kit_id)
{ {
$this->authorize('view', PredefinedKit::class); $this->authorize('view', PredefinedKit::class);
$kit = PredefinedKit::findOrFail($kit_id); $kit = PredefinedKit::findOrFail($kit_id);
@@ -247,8 +236,9 @@ class PredefinedKitsController extends Controller
* Store the specified resource. * Store the specified resource.
* *
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function storeModel(Request $request, $kit_id) : JsonResponse public function storeModel(Request $request, $kit_id)
{ {
$this->authorize('update', PredefinedKit::class); $this->authorize('update', PredefinedKit::class);
@@ -262,7 +252,7 @@ class PredefinedKitsController extends Controller
$relation = $kit->models(); $relation = $kit->models();
if ($relation->find($model_id)) { if ($relation->find($model_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, ['model' => trans('admin/kits/general.model_already_attached')])); return response()->json(Helper::formatStandardApiResponse('error', null, ['model' => 'Model already attached to kit']));
} }
$relation->attach($model_id, ['quantity' => $quantity]); $relation->attach($model_id, ['quantity' => $quantity]);
@@ -274,8 +264,9 @@ class PredefinedKitsController extends Controller
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param int $kit_id * @param int $kit_id
* @return \Illuminate\Http\Response
*/ */
public function updateModel(Request $request, $kit_id, $model_id) : JsonResponse public function updateModel(Request $request, $kit_id, $model_id)
{ {
$this->authorize('update', PredefinedKit::class); $this->authorize('update', PredefinedKit::class);
$kit = PredefinedKit::findOrFail($kit_id); $kit = PredefinedKit::findOrFail($kit_id);
@@ -292,8 +283,9 @@ class PredefinedKitsController extends Controller
* Remove the specified resource from storage. * Remove the specified resource from storage.
* *
* @param int $kit_id * @param int $kit_id
* @return \Illuminate\Http\Response
*/ */
public function detachModel($kit_id, $model_id) : JsonResponse public function detachModel($kit_id, $model_id)
{ {
$this->authorize('update', PredefinedKit::class); $this->authorize('update', PredefinedKit::class);
$kit = PredefinedKit::findOrFail($kit_id); $kit = PredefinedKit::findOrFail($kit_id);
@@ -307,8 +299,9 @@ class PredefinedKitsController extends Controller
* Display the specified resource. * Display the specified resource.
* *
* @param int $kit_id * @param int $kit_id
* @return \Illuminate\Http\Response
*/ */
public function indexConsumables($kit_id) : array public function indexConsumables($kit_id)
{ {
$this->authorize('view', PredefinedKit::class); $this->authorize('view', PredefinedKit::class);
$kit = PredefinedKit::findOrFail($kit_id); $kit = PredefinedKit::findOrFail($kit_id);
@@ -321,8 +314,9 @@ class PredefinedKitsController extends Controller
* Store the specified resource. * Store the specified resource.
* *
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function storeConsumable(Request $request, $kit_id) : JsonResponse public function storeConsumable(Request $request, $kit_id)
{ {
$this->authorize('update', PredefinedKit::class); $this->authorize('update', PredefinedKit::class);
@@ -348,8 +342,9 @@ class PredefinedKitsController extends Controller
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param int $kit_id * @param int $kit_id
* @return \Illuminate\Http\Response
*/ */
public function updateConsumable(Request $request, $kit_id, $consumable_id) : JsonResponse public function updateConsumable(Request $request, $kit_id, $consumable_id)
{ {
$this->authorize('update', PredefinedKit::class); $this->authorize('update', PredefinedKit::class);
$kit = PredefinedKit::findOrFail($kit_id); $kit = PredefinedKit::findOrFail($kit_id);
@@ -366,8 +361,9 @@ class PredefinedKitsController extends Controller
* Remove the specified resource from storage. * Remove the specified resource from storage.
* *
* @param int $kit_id * @param int $kit_id
* @return \Illuminate\Http\Response
*/ */
public function detachConsumable($kit_id, $consumable_id) : JsonResponse public function detachConsumable($kit_id, $consumable_id)
{ {
$this->authorize('update', PredefinedKit::class); $this->authorize('update', PredefinedKit::class);
$kit = PredefinedKit::findOrFail($kit_id); $kit = PredefinedKit::findOrFail($kit_id);
@@ -381,8 +377,9 @@ class PredefinedKitsController extends Controller
* Display the specified resource. * Display the specified resource.
* *
* @param int $kit_id * @param int $kit_id
* @return \Illuminate\Http\Response
*/ */
public function indexAccessories($kit_id) : array public function indexAccessories($kit_id)
{ {
$this->authorize('view', PredefinedKit::class); $this->authorize('view', PredefinedKit::class);
$kit = PredefinedKit::findOrFail($kit_id); $kit = PredefinedKit::findOrFail($kit_id);
@@ -395,8 +392,9 @@ class PredefinedKitsController extends Controller
* Store the specified resource. * Store the specified resource.
* *
* @param int $kit_id * @param int $kit_id
* @return \Illuminate\Http\Response
*/ */
public function storeAccessory(Request $request, $kit_id) : JsonResponse public function storeAccessory(Request $request, $kit_id)
{ {
$this->authorize('update', PredefinedKit::class); $this->authorize('update', PredefinedKit::class);
@@ -422,8 +420,9 @@ class PredefinedKitsController extends Controller
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param int $kit_id * @param int $kit_id
* @return \Illuminate\Http\Response
*/ */
public function updateAccessory(Request $request, $kit_id, $accessory_id) : JsonResponse public function updateAccessory(Request $request, $kit_id, $accessory_id)
{ {
$this->authorize('update', PredefinedKit::class); $this->authorize('update', PredefinedKit::class);
$kit = PredefinedKit::findOrFail($kit_id); $kit = PredefinedKit::findOrFail($kit_id);
@@ -440,8 +439,9 @@ class PredefinedKitsController extends Controller
* Remove the specified resource from storage. * Remove the specified resource from storage.
* *
* @param int $kit_id * @param int $kit_id
* @return \Illuminate\Http\Response
*/ */
public function detachAccessory($kit_id, $accessory_id) : JsonResponse public function detachAccessory($kit_id, $accessory_id)
{ {
$this->authorize('update', PredefinedKit::class); $this->authorize('update', PredefinedKit::class);
$kit = PredefinedKit::findOrFail($kit_id); $kit = PredefinedKit::findOrFail($kit_id);
+20 -35
View File
@@ -4,19 +4,15 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper; use App\Helpers\Helper;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Transformers\ProfileTransformer;
use App\Models\CheckoutRequest; use App\Models\CheckoutRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Laravel\Passport\TokenRepository; use Laravel\Passport\TokenRepository;
use Illuminate\Contracts\Validation\Factory as ValidationFactory; use Illuminate\Contracts\Validation\Factory as ValidationFactory;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use App\Models\CustomField; use App\Models\CustomField;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class ProfileController extends Controller class ProfileController extends Controller
{ {
@@ -46,10 +42,12 @@ class ProfileController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.3.0] * @since [v4.3.0]
*
* @return array
*/ */
public function requestedAssets() : array public function requestedAssets()
{ {
$checkoutRequests = CheckoutRequest::where('user_id', '=', auth()->id())->get(); $checkoutRequests = CheckoutRequest::where('user_id', '=', Auth::user()->id)->get();
$results = array(); $results = array();
$show_field = array(); $show_field = array();
@@ -69,7 +67,7 @@ class ProfileController extends Controller
if ($checkoutRequest && $checkoutRequest->itemRequested()) { if ($checkoutRequest && $checkoutRequest->itemRequested()) {
$assets = [ $assets = [
'image' => e($checkoutRequest->itemRequested()->present()->getImageUrl()), 'image' => e($checkoutRequest->itemRequested()->present()->getImageUrl()),
'name' => e($checkoutRequest->itemRequested()->display_name), 'name' => e($checkoutRequest->itemRequested()->present()->name()),
'type' => e($checkoutRequest->itemType()), 'type' => e($checkoutRequest->itemType()),
'qty' => (int) $checkoutRequest->quantity, 'qty' => (int) $checkoutRequest->quantity,
'location' => ($checkoutRequest->location()) ? e($checkoutRequest->location()->name) : null, 'location' => ($checkoutRequest->location()) ? e($checkoutRequest->location()->name) : null,
@@ -97,9 +95,10 @@ class ProfileController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v6.0.5] * @since [v6.0.5]
*
* @return \Illuminate\Http\Response
*/ */
public function createApiToken(Request $request) : JsonResponse public function createApiToken(Request $request) {
{
if (!Gate::allows('self.api')) { if (!Gate::allows('self.api')) {
abort(403); abort(403);
@@ -107,14 +106,14 @@ class ProfileController extends Controller
$accessTokenName = $request->input('name', 'Auth Token'); $accessTokenName = $request->input('name', 'Auth Token');
if ($accessToken = auth()->user()->createToken($accessTokenName)->accessToken) { if ($accessToken = Auth::user()->createToken($accessTokenName)->accessToken) {
// Get the ID so we can return that with the payload // Get the ID so we can return that with the payload
$token = DB::table('oauth_access_tokens')->where('user_id', '=', auth()->id())->where('name','=',$accessTokenName)->orderBy('created_at', 'desc')->first(); $token = DB::table('oauth_access_tokens')->where('user_id', '=', Auth::user()->id)->where('name','=',$accessTokenName)->orderBy('created_at', 'desc')->first();
$accessTokenData['id'] = $token->id; $accessTokenData['id'] = $token->id;
$accessTokenData['token'] = $accessToken; $accessTokenData['token'] = $accessToken;
$accessTokenData['name'] = $accessTokenName; $accessTokenData['name'] = $accessTokenName;
return response()->json(Helper::formatStandardApiResponse('success', $accessTokenData, trans('account/general.personal_api_keys_success', ['key' => $accessTokenName]))); return response()->json(Helper::formatStandardApiResponse('success', $accessTokenData, 'Personal access token '.$accessTokenName.' created successfully'));
} }
return response()->json(Helper::formatStandardApiResponse('error', null, 'Token could not be created.')); return response()->json(Helper::formatStandardApiResponse('error', null, 'Token could not be created.'));
@@ -126,16 +125,17 @@ class ProfileController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v6.0.5] * @since [v6.0.5]
*
* @return \Illuminate\Http\Response
*/ */
public function deleteApiToken($tokenId) : Response public function deleteApiToken($tokenId) {
{
if (!Gate::allows('self.api')) { if (!Gate::allows('self.api')) {
abort(403); abort(403);
} }
$token = $this->tokenRepository->findForUser( $token = $this->tokenRepository->findForUser(
$tokenId, auth()->user()->getAuthIdentifier() $tokenId, Auth::user()->getAuthIdentifier()
); );
if (is_null($token)) { if (is_null($token)) {
@@ -154,15 +154,16 @@ class ProfileController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v6.0.5] * @since [v6.0.5]
*
* @return \Illuminate\Http\Response
*/ */
public function showApiTokens() : JsonResponse public function showApiTokens(Request $request) {
{
if (!Gate::allows('self.api')) { if (!Gate::allows('self.api')) {
abort(403); abort(403);
} }
$tokens = $this->tokenRepository->forUser(auth()->user()->getAuthIdentifier()); $tokens = $this->tokenRepository->forUser(Auth::user()->getAuthIdentifier());
$token_values = $tokens->load('client')->filter(function ($token) { $token_values = $tokens->load('client')->filter(function ($token) {
return $token->client->personal_access_client && ! $token->revoked; return $token->client->personal_access_client && ! $token->revoked;
})->values(); })->values();
@@ -171,22 +172,6 @@ class ProfileController extends Controller
} }
/**
* Display the EULAs accepted by the user.
*
* @param \App\Http\Transformers\ActionlogsTransformer $transformer
* @return \Illuminate\Http\JsonResponse
*@since [v8.1.16]
* @author [Godfrey Martinez] [<gmartinez@grokability.com>]
*/
public function eulas(ProfileTransformer $transformer)
{
// Only return this user's EULAs
$eulas = auth()->user()->eulas;
return response()->json(
$transformer->transformFiles($eulas, $eulas->count())
);
}
} }
+15 -30
View File
@@ -5,10 +5,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Transformers\ActionlogsTransformer; use App\Http\Transformers\ActionlogsTransformer;
use App\Models\Actionlog; use App\Models\Actionlog;
use App\Models\Company;
use App\Models\Setting;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class ReportsController extends Controller class ReportsController extends Controller
{ {
@@ -17,13 +14,13 @@ class ReportsController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @return View
*/ */
public function index(Request $request) : JsonResponse | array public function index(Request $request)
{ {
$this->authorize('activity.view'); $this->authorize('reports.view');
$actionlogs = Actionlog::with('item', 'user', 'adminuser', 'target', 'location');
$actionlogs = Actionlog::with('item', 'user', 'admin', 'target', 'location');
if ($request->filled('search')) { if ($request->filled('search')) {
$actionlogs = $actionlogs->TextSearch(e($request->input('search'))); $actionlogs = $actionlogs->TextSearch(e($request->input('search')));
@@ -48,40 +45,36 @@ class ReportsController extends Controller
} }
if ($request->filled('action_type')) { if ($request->filled('action_type')) {
$actionlogs = $actionlogs->where('action_type', '=', $request->input('action_type')); $actionlogs = $actionlogs->where('action_type', '=', $request->input('action_type'))->orderBy('created_at', 'desc');
} }
if ($request->filled('created_by')) { if ($request->filled('user_id')) {
$actionlogs = $actionlogs->where('created_by', '=', $request->input('created_by')); $actionlogs = $actionlogs->where('user_id', '=', $request->input('user_id'));
} }
if ($request->filled('action_source')) { if ($request->filled('action_source')) {
$actionlogs = $actionlogs->where('action_source', '=', $request->input('action_source')); $actionlogs = $actionlogs->where('action_source', '=', $request->input('action_source'))->orderBy('created_at', 'desc');
}
if ($request->filled('remote_ip')) {
$actionlogs = $actionlogs->where('remote_ip', '=', $request->input('remote_ip'));
} }
if ($request->filled('remote_ip')) {
$actionlogs = $actionlogs->where('remote_ip', '=', $request->input('remote_ip'))->orderBy('created_at', 'desc');
}
if ($request->filled('uploads')) { if ($request->filled('uploads')) {
$actionlogs = $actionlogs->whereNotNull('filename'); $actionlogs = $actionlogs->whereNotNull('filename')->orderBy('created_at', 'desc');
} }
$allowed_columns = [ $allowed_columns = [
'id', 'id',
'created_at', 'created_at',
'target_id', 'target_id',
'created_by', 'user_id',
'accept_signature', 'accept_signature',
'action_type', 'action_type',
'note', 'note',
'remote_ip', 'remote_ip',
'user_agent', 'user_agent',
'target_type',
'item_type',
'action_source', 'action_source',
'action_date',
]; ];
@@ -90,19 +83,11 @@ class ReportsController extends Controller
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value'); $offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value'); $limit = app('api_limit_value');
$sort = in_array($request->input('sort'), $allowed_columns) ? e($request->input('sort')) : 'created_at';
$order = ($request->input('order') == 'asc') ? 'asc' : 'desc'; $order = ($request->input('order') == 'asc') ? 'asc' : 'desc';
switch ($request->input('sort')) {
case 'created_by':
$actionlogs->OrderByCreatedBy($order);
break;
default:
$sort = in_array($request->input('sort'), $allowed_columns) ? e($request->input('sort')) : 'action_logs.created_at';
$actionlogs = $actionlogs->orderBy($sort, $order);
break;
}
$actionlogs = $actionlogs->skip($offset)->take($limit)->get(); $actionlogs = $actionlogs->orderBy($sort, $order)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($actionlogs, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE); return response()->json((new ActionlogsTransformer)->transformActionlogs($actionlogs, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
} }
+23 -30
View File
@@ -3,27 +3,32 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Helpers\Helper; use App\Helpers\Helper;
use App\Helpers\StorageHelper;
use App\Http\Transformers\DatatablesTransformer; use App\Http\Transformers\DatatablesTransformer;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Ldap; use App\Models\Ldap;
use App\Models\Setting; use App\Models\Setting;
use Mail;
use App\Notifications\SlackTest;
use App\Notifications\MailTest; use App\Notifications\MailTest;
use GuzzleHttp\Client;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use App\Http\Requests\SlackSettingsRequest;
use App\Http\Transformers\LoginAttemptsTransformer; use App\Http\Transformers\LoginAttemptsTransformer;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class SettingsController extends Controller class SettingsController extends Controller
{ {
public function ldaptest() : JsonResponse public function ldaptest()
{ {
$settings = Setting::getSettings(); $settings = Setting::getSettings();
@@ -50,22 +55,10 @@ class SettingsController extends Controller
})->slice(0, 10)->map(function ($item) use ($settings) { })->slice(0, 10)->map(function ($item) use ($settings) {
return (object) [ return (object) [
'username' => $item[$settings['ldap_username_field']][0] ?? null, 'username' => $item[$settings['ldap_username_field']][0] ?? null,
'display_name' => $item[$settings['ldap_display_name']][0] ?? null,
'employee_number' => $item[$settings['ldap_emp_num']][0] ?? null, 'employee_number' => $item[$settings['ldap_emp_num']][0] ?? null,
'lastname' => $item[$settings['ldap_lname_field']][0] ?? null, 'lastname' => $item[$settings['ldap_lname_field']][0] ?? null,
'firstname' => $item[$settings['ldap_fname_field']][0] ?? null, 'firstname' => $item[$settings['ldap_fname_field']][0] ?? null,
'email' => $item[$settings['ldap_email']][0] ?? null, 'email' => $item[$settings['ldap_email']][0] ?? null,
'phone' => $item[$settings['ldap_phone_field']][0] ?? null,
'mobile' => $item[$settings['ldap_mobile']][0] ?? null,
'jobtitle' => $item[$settings['ldap_jobtitle']][0] ?? null,
'department' => $item[$settings['ldap_department']][0] ?? null,
'manager' => $item[$settings['ldap_manager']][0] ?? null,
'address' => $item[$settings['ldap_address']][0] ?? null,
'city' => $item[$settings['ldap_city']][0] ?? null,
'state' => $item[$settings['ldap_state']][0] ?? null,
'zip' => $item[$settings['ldap_zip']][0] ?? null,
'country' => $item[$settings['ldap_country']][0] ?? null,
'location' => $item[$settings['ldap_location']][0] ?? null,
]; ];
}); });
if ($users->count() > 0) { if ($users->count() > 0) {
@@ -89,13 +82,13 @@ class SettingsController extends Controller
} }
} catch (\Exception $e) { } catch (\Exception $e) {
Log::debug('Connection failed but we cannot debug it any further on our end.'); Log::debug('Connection failed but we cannot debug it any further on our end.');
return response()->json(['message' => $e->getMessage()], 400); return response()->json(['message' => $e->getMessage()], 500);
} }
} }
public function ldaptestlogin(Request $request) : JsonResponse public function ldaptestlogin(Request $request)
{ {
if (Setting::getSettings()->ldap_enabled != '1') { if (Setting::getSettings()->ldap_enabled != '1') {
@@ -155,17 +148,15 @@ class SettingsController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v3.0] * @since [v3.0]
* @return JsonResponse
*/ */
public function ajaxTestEmail() : JsonResponse public function ajaxTestEmail()
{ {
if (!config('app.lock_passwords')) { if (!config('app.lock_passwords')) {
try { try {
Notification::send(Setting::first(), new MailTest()); Notification::send(Setting::first(), new MailTest());
Log::debug('Attempting to sending to '.config('mail.reply_to.address'));
return response()->json(['message' => 'Mail sent to '.config('mail.reply_to.address')], 200); return response()->json(['message' => 'Mail sent to '.config('mail.reply_to.address')], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Mail sent error using '.config('mail.reply_to.address') .': '. $e->getMessage());
Log::debug($e);
return response()->json(['message' => $e->getMessage()], 500); return response()->json(['message' => $e->getMessage()], 500);
} }
} }
@@ -179,8 +170,9 @@ class SettingsController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v5.0.0] * @since [v5.0.0]
* @return JsonResponse
*/ */
public function purgeBarcodes() : JsonResponse public function purgeBarcodes()
{ {
$file_count = 0; $file_count = 0;
$files = Storage::disk('public')->files('barcodes'); $files = Storage::disk('public')->files('barcodes');
@@ -219,8 +211,9 @@ class SettingsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v5.0.0] * @since [v5.0.0]
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @return array | JsonResponse
*/ */
public function showLoginAttempts(Request $request) : array public function showLoginAttempts(Request $request)
{ {
$allowed_columns = ['id', 'username', 'remote_ip', 'user_agent', 'successful', 'created_at']; $allowed_columns = ['id', 'username', 'remote_ip', 'user_agent', 'successful', 'created_at'];
@@ -240,9 +233,9 @@ class SettingsController extends Controller
* Lists backup files * Lists backup files
* *
* @author [A. Gianotto] * @author [A. Gianotto]
* @return array | JsonResponse
*/ */
public function listBackups() : array public function listBackups() {
{
$settings = Setting::getSettings(); $settings = Setting::getSettings();
$path = 'app/backups'; $path = 'app/backups';
$backup_files = Storage::files($path); $backup_files = Storage::files($path);
@@ -283,9 +276,9 @@ class SettingsController extends Controller
* exhausts memory on larger files. * exhausts memory on larger files.
* *
* @author [A. Gianotto] * @author [A. Gianotto]
* @return JsonResponse|\Symfony\Component\HttpFoundation\BinaryFileResponse
*/ */
public function downloadBackup($file) : JsonResponse | BinaryFileResponse public function downloadBackup($file) {
{
$path = storage_path('app/backups'); $path = storage_path('app/backups');
@@ -303,9 +296,9 @@ class SettingsController extends Controller
* *
* @author [A. Gianotto] * @author [A. Gianotto]
* @since [v6.3.1] * @since [v6.3.1]
* @return JsonResponse|\Symfony\Component\HttpFoundation\BinaryFileResponse
*/ */
public function downloadLatestBackup() : JsonResponse | BinaryFileResponse public function downloadLatestBackup() {
{
$fileData = collect(); $fileData = collect();
foreach (Storage::files('app/backups') as $file) { foreach (Storage::files('app/backups') as $file) {
@@ -329,4 +322,4 @@ class SettingsController extends Controller
} }
} }
@@ -8,11 +8,10 @@ use App\Http\Transformers\AssetsTransformer;
use App\Http\Transformers\SelectlistTransformer; use App\Http\Transformers\SelectlistTransformer;
use App\Http\Transformers\StatuslabelsTransformer; use App\Http\Transformers\StatuslabelsTransformer;
use App\Models\Asset; use App\Models\Asset;
use App\Models\Setting;
use App\Models\Statuslabel; use App\Models\Statuslabel;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Transformers\PieChartTransformer; use App\Http\Transformers\PieChartTransformer;
use Illuminate\Http\JsonResponse; use Illuminate\Support\Arr;
class StatuslabelsController extends Controller class StatuslabelsController extends Controller
{ {
@@ -21,21 +20,14 @@ class StatuslabelsController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @return \Illuminate\Http\Response
*/ */
public function index(Request $request) : array public function index(Request $request)
{ {
$this->authorize('view', Statuslabel::class); $this->authorize('view', Statuslabel::class);
$allowed_columns = [ $allowed_columns = ['id', 'name', 'created_at', 'assets_count', 'color', 'notes', 'default_label'];
'id',
'name',
'created_at',
'assets_count',
'color',
'notes',
'default_label'
];
$statuslabels = Statuslabel::with('adminuser')->withCount('assets as assets_count'); $statuslabels = Statuslabel::withCount('assets as assets_count');
if ($request->filled('search')) { if ($request->filled('search')) {
$statuslabels = $statuslabels->TextSearch($request->input('search')); $statuslabels = $statuslabels->TextSearch($request->input('search'));
@@ -62,18 +54,10 @@ class StatuslabelsController extends Controller
// Make sure the offset and limit are actually integers and do not exceed system limits // Make sure the offset and limit are actually integers and do not exceed system limits
$offset = ($request->input('offset') > $statuslabels->count()) ? $statuslabels->count() : app('api_offset_value'); $offset = ($request->input('offset') > $statuslabels->count()) ? $statuslabels->count() : app('api_offset_value');
$limit = app('api_limit_value'); $limit = app('api_limit_value');
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
$sort_override = $request->input('sort');
$column_sort = in_array($sort_override, $allowed_columns) ? $sort_override : 'created_at';
switch ($sort_override) { $order = $request->input('order') === 'asc' ? 'asc' : 'desc';
case 'created_by': $sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'created_at';
$statuslabels = $statuslabels->OrderByCreatedBy($order); $statuslabels->orderBy($sort, $order);
break;
default:
$statuslabels = $statuslabels->orderBy($column_sort, $order);
break;
}
$total = $statuslabels->count(); $total = $statuslabels->count();
$statuslabels = $statuslabels->skip($offset)->take($limit)->get(); $statuslabels = $statuslabels->skip($offset)->take($limit)->get();
@@ -88,15 +72,15 @@ class StatuslabelsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/ */
public function store(Request $request) : JsonResponse public function store(Request $request)
{ {
$this->authorize('create', Statuslabel::class); $this->authorize('create', Statuslabel::class);
$request->except('deployable', 'pending', 'archived'); $request->except('deployable', 'pending', 'archived');
if (! $request->filled('type')) { if (! $request->filled('type')) {
return response()->json(Helper::formatStandardApiResponse('error', null, ['type' => ['Status label type is required.']]), 500);
return response()->json(Helper::formatStandardApiResponse('error', null, ['type' => ['Status label type is required.']]));
} }
$statuslabel = new Statuslabel; $statuslabel = new Statuslabel;
@@ -124,8 +108,9 @@ class StatuslabelsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function show($id) : array public function show($id)
{ {
$this->authorize('view', Statuslabel::class); $this->authorize('view', Statuslabel::class);
$statuslabel = Statuslabel::findOrFail($id); $statuslabel = Statuslabel::findOrFail($id);
@@ -141,8 +126,9 @@ class StatuslabelsController extends Controller
* @since [v4.0] * @since [v4.0]
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function update(Request $request, $id) : JsonResponse public function update(Request $request, $id)
{ {
$this->authorize('update', Statuslabel::class); $this->authorize('update', Statuslabel::class);
$statuslabel = Statuslabel::findOrFail($id); $statuslabel = Statuslabel::findOrFail($id);
@@ -177,8 +163,9 @@ class StatuslabelsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function destroy($id) : JsonResponse public function destroy($id)
{ {
$this->authorize('delete', Statuslabel::class); $this->authorize('delete', Statuslabel::class);
$statuslabel = Statuslabel::findOrFail($id); $statuslabel = Statuslabel::findOrFail($id);
@@ -201,18 +188,13 @@ class StatuslabelsController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v3.0] * @since [v3.0]
* @return array
*/ */
public function getAssetCountByStatuslabel() : array public function getAssetCountByStatuslabel()
{ {
$this->authorize('view', Statuslabel::class); $this->authorize('view', Statuslabel::class);
$statuslabels = Statuslabel::withCount('assets')->get();
if (Setting::getSettings()->show_archived_in_list == 0 ) { $total = Array();
$statuslabels = Statuslabel::withCount('assets')->where('archived','0')->get();
} else {
$statuslabels = Statuslabel::withCount('assets')->get();
}
$total = [];
foreach ($statuslabels as $statuslabel) { foreach ($statuslabels as $statuslabel) {
@@ -233,8 +215,9 @@ class StatuslabelsController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v6.0.11] * @since [v6.0.11]
* @return array
*/ */
public function getAssetCountByMetaStatus() : array public function getAssetCountByMetaStatus()
{ {
$this->authorize('view', Statuslabel::class); $this->authorize('view', Statuslabel::class);
@@ -262,8 +245,9 @@ class StatuslabelsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function assets(Request $request, $id) : array public function assets(Request $request, $id)
{ {
$this->authorize('view', Statuslabel::class); $this->authorize('view', Statuslabel::class);
$this->authorize('index', Asset::class); $this->authorize('index', Asset::class);
@@ -290,20 +274,19 @@ class StatuslabelsController extends Controller
/** /**
* Returns a boolean response based on whether the status label * Returns a boolean response based on whether the status label
* is one that is deployable or pending. * is one that is deployable.
* *
* This is used by the hardware create/edit view to determine whether * This is used by the hardware create/edit view to determine whether
* we should provide a dropdown of users for them to check the asset out to, * we should provide a dropdown of users for them to check the asset out to.
* and whether we show a warning that the asset will be checked in if it's already
* assigned but the status is changed to one that isn't pending or deployable
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @return bool
*/ */
public function checkIfDeployable($id) : string public function checkIfDeployable($id)
{ {
$statuslabel = Statuslabel::findOrFail($id); $statuslabel = Statuslabel::findOrFail($id);
if (($statuslabel->getStatuslabelType() == 'pending') || ($statuslabel->getStatuslabelType() == 'deployable')) { if ($statuslabel->getStatuslabelType() == 'deployable') {
return '1'; return '1';
} }
@@ -317,7 +300,7 @@ class StatuslabelsController extends Controller
* @since [v6.1.1] * @since [v6.1.1]
* @see \App\Http\Transformers\SelectlistTransformer * @see \App\Http\Transformers\SelectlistTransformer
*/ */
public function selectlist(Request $request) : array public function selectlist(Request $request)
{ {
$this->authorize('view.selectlists'); $this->authorize('view.selectlists');
@@ -10,7 +10,6 @@ use App\Models\Supplier;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Requests\ImageUploadRequest; use App\Http\Requests\ImageUploadRequest;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Http\JsonResponse;
class SuppliersController extends Controller class SuppliersController extends Controller
{ {
@@ -21,18 +20,13 @@ class SuppliersController extends Controller
* @since [v4.0] * @since [v4.0]
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*/ */
public function index(Request $request): array public function index(Request $request)
{ {
$this->authorize('view', Supplier::class); $this->authorize('view', Supplier::class);
$allowed_columns = [ $allowed_columns = ['
'id', id',
'name', 'name',
'address', 'address',
'address2',
'city',
'state',
'country',
'zip',
'phone', 'phone',
'contact', 'contact',
'fax', 'fax',
@@ -44,24 +38,21 @@ class SuppliersController extends Controller
'components_count', 'components_count',
'consumables_count', 'consumables_count',
'url', 'url',
'notes',
]; ];
$suppliers = Supplier::select( $suppliers = Supplier::select(
['id', 'name', 'address', 'address2', 'city', 'state', 'country', 'fax', 'phone', 'email', 'contact', 'created_at', 'created_by', 'updated_at', 'deleted_at', 'image', 'notes', 'url', 'zip']) ['id', 'name', 'address', 'address2', 'city', 'state', 'country', 'fax', 'phone', 'email', 'contact', 'created_at', 'updated_at', 'deleted_at', 'image', 'notes', 'url'])
->withCount('assets as assets_count') ->withCount('assets as assets_count')
->withCount('licenses as licenses_count') ->withCount('licenses as licenses_count')
->withCount('accessories as accessories_count') ->withCount('accessories as accessories_count')
->withCount('components as components_count') ->withCount('components as components_count')
->withCount('consumables as consumables_count') ->withCount('consumables as consumables_count');
->with('adminuser');
if ($request->filled('search')) { if ($request->filled('search')) {
$suppliers->TextSearch($request->input('search')); $suppliers = $suppliers->TextSearch($request->input('search'));
} }
if ($request->filled('name')) { if ($request->filled('name')) {
$suppliers->where('name', '=', $request->input('name')); $suppliers->where('name', '=', $request->input('name'));
} }
@@ -108,15 +99,7 @@ class SuppliersController extends Controller
$order = $request->input('order') === 'asc' ? 'asc' : 'desc'; $order = $request->input('order') === 'asc' ? 'asc' : 'desc';
$sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'created_at'; $sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'created_at';
$suppliers->orderBy($sort, $order);
switch ($request->input('sort')) {
case 'created_by':
$suppliers->OrderByCreatedByName($order);
break;
default:
$suppliers->orderBy($sort, $order);
break;
}
$total = $suppliers->count(); $total = $suppliers->count();
$suppliers = $suppliers->skip($offset)->take($limit)->get(); $suppliers = $suppliers->skip($offset)->take($limit)->get();
@@ -131,8 +114,9 @@ class SuppliersController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param \App\Http\Requests\ImageUploadRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @return \Illuminate\Http\Response
*/ */
public function store(ImageUploadRequest $request) : JsonResponse public function store(ImageUploadRequest $request)
{ {
$this->authorize('create', Supplier::class); $this->authorize('create', Supplier::class);
$supplier = new Supplier; $supplier = new Supplier;
@@ -152,8 +136,9 @@ class SuppliersController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function show($id) : array public function show($id)
{ {
$this->authorize('view', Supplier::class); $this->authorize('view', Supplier::class);
$supplier = Supplier::findOrFail($id); $supplier = Supplier::findOrFail($id);
@@ -169,8 +154,9 @@ class SuppliersController extends Controller
* @since [v4.0] * @since [v4.0]
* @param \App\Http\Requests\ImageUploadRequest $request * @param \App\Http\Requests\ImageUploadRequest $request
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function update(ImageUploadRequest $request, $id) : JsonResponse public function update(ImageUploadRequest $request, $id)
{ {
$this->authorize('update', Supplier::class); $this->authorize('update', Supplier::class);
$supplier = Supplier::findOrFail($id); $supplier = Supplier::findOrFail($id);
@@ -190,11 +176,12 @@ class SuppliersController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id * @param int $id
* @return \Illuminate\Http\Response
*/ */
public function destroy($id) : JsonResponse public function destroy($id)
{ {
$this->authorize('delete', Supplier::class); $this->authorize('delete', Supplier::class);
$supplier = Supplier::with('maintenances', 'assets', 'licenses')->withCount('maintenances as maintenances_count', 'assets as assets_count', 'licenses as licenses_count')->findOrFail($id); $supplier = Supplier::with('asset_maintenances', 'assets', 'licenses')->withCount('asset_maintenances as asset_maintenances_count', 'assets as assets_count', 'licenses as licenses_count')->findOrFail($id);
$this->authorize('delete', $supplier); $this->authorize('delete', $supplier);
@@ -202,8 +189,8 @@ class SuppliersController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/suppliers/message.delete.assoc_assets', ['asset_count' => (int) $supplier->assets_count]))); return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/suppliers/message.delete.assoc_assets', ['asset_count' => (int) $supplier->assets_count])));
} }
if ($supplier->maintenances_count > 0) { if ($supplier->asset_maintenances_count > 0) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/suppliers/message.delete.assoc_maintenances', ['maintenances_count' => $supplier->maintenances_count]))); return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/suppliers/message.delete.assoc_maintenances', ['asset_maintenances_count' => $supplier->asset_maintenances_count])));
} }
if ($supplier->licenses_count > 0) { if ($supplier->licenses_count > 0) {
@@ -222,7 +209,7 @@ class SuppliersController extends Controller
* @since [v4.0.16] * @since [v4.0.16]
* @see \App\Http\Transformers\SelectlistTransformer * @see \App\Http\Transformers\SelectlistTransformer
*/ */
public function selectlist(Request $request) : array public function selectlist(Request $request)
{ {
$this->authorize('view.selectlists'); $this->authorize('view.selectlists');

Some files were not shown because too many files have changed in this diff Show More