يوفر هذا المستودع خادم تسجيل الدخول لاستخدامه في أداة تعيين Cocoda. يتيح للمستخدمين المصادقة باستخدام مقدمي الخدمات المختلفين (على سبيل المثال Github ، orcid). انظر https://coli-conc.gbv.de/login/api للحصول على مثال على كيفية استخدام هذا.
.envproviders.jsonapplications.jsonيتطلب Login-Server Node.js (> = V18 ، V20 الموصى بها) والوصول إلى قاعدة بيانات MongoDB (> = V5 ، V7 الموصى بها).
git clone https://github.com/gbv/login-server.git
cd login-server
npm install
# after setting up or changing providers, create indexes
npm run indexesخادم تسجيل الدخول متاح أيضًا عبر Docker. يرجى الرجوع إلى الوثائق على https://github.com/gbv/login-server/blob/master/docker/readme.md لمزيد من التفاصيل.
إذا كان تشغيل الخادم خلف وكيل عكسي ، فتأكد من تضمين رأس X-Forwarded-Proto ، والسماح لجميع أساليب HTTP ، وتمكين Proxying WebSocket.
تحتاج إلى توفير ملفين للتكوين:
.envلتكوين التطبيق:
# recommended, port for express, default: 3004
PORT=
# recommended, full base URL, default: http://localhost[:PORT]/
# (required when used in production or behind a reverse proxy)
BASE_URL=
# title of application (will be shown in header)
TITLE=My Login Server
# list of allowed origins separated by comma, includes the hostname of BASE_URL by default
ALLOWED_ORIGINS=
# required for some strategies to enable production mode, default: development
NODE_ENV=production
# strongly recommended, imprint and privacy URLs for footer and clients
IMPRINT_URL=
PRIVACY_URL=
# recommended, secret used by the session
SESSION_SECRET=
# optional, maximum number of days a session is valid (rolling), default: 30
COOKIE_MAX_DAYS=
# threshold in minutes when to send "sessionAboutToExpire" events, default: 60
SESSION_EXPIRATION_MESSAGE_THRESHOLD=
# interval in minutes in which to check for expiring sessions, default: 5
SESSION_EXPIRATION_MESSAGE_INTERVAL=
# username used for MongoDB, default: <empty>
MONGO_USER=
# password used for MongoDB, default: <empty>
MONGO_PASS=
# host used for MongoDB, default: 127.0.0.1
MONGO_HOST=
# port used for MongoDB, default: 27017
MONGO_PORT=
# database used for MongoDB, default: login-server
MONGO_DB=
# the rate limit window in ms, default: 60 * 1000
RATE_LIMIT_WINDOW=
# the rate limit tries, default: 10
RATE_LIMIT_MAX=
# a jsonwebtoken compatible keypair
JWT_PRIVATE_KEY_PATH=
JWT_PUBLIC_KEY_PATH=
# the jsonwebtoken algorithm used
JWT_ALGORITHM=
# expiration time of JWTs in seconds, default: 120, min: 10
JWT_EXPIRES_IN=
# URL for Sources, default: https://github.com/gbv/login-server
SOURCES_URL=
# the path to the providers.json file, default: ./providers.json
PROVIDERS_PATH=
# log = log all messages, warn = only log warnings and errors, error = only log errorsl default: log
VERBOSITY=providers.jsonلتكوين مقدمي الخدمات. انظر مقدمي الخدمات.
applications.json لتزويد المستخدم بمعلومات حول التطبيقات التي تصل إلى بياناتهم ، والتي بدأ التطبيق تسجيل الدخول إلى الجلسة ، يمكنك تقديم قائمة بالتطبيقات في applications.json . يجب أن تكون القائمة مجموعة من الكائنات ويجب أن يكون لكل كائن عنوان url name . مثال:
[
{
"url" : " https://bartoc.org " ,
"name" : " BARTOC "
},
{
"url" : " https://coli-conc.gbv.de/coli-rich/ " ,
"name" : " coli-rich "
},
{
"url" : " https://coli-conc.gbv.de/cocoda/app/ " ,
"name" : " Cocoda "
},
{
"url" : " https://coli-conc.gbv.de/cocoda/dev/ " ,
"name" : " Cocoda (dev) "
},
{
"url" : " https://coli-conc.gbv.de/cocoda/rvk/ " ,
"name" : " Cocoda (RVK) "
},
{
"url" : " https://coli-conc.gbv.de/cocoda/wikidata/ " ,
"name" : " Cocoda (Wikidata) "
},
{
"url" : " https://coli-conc.gbv.de/cocoda/ " ,
"name" : " Cocoda (other) "
},
{
"url" : " https://coli-conc.gbv.de " ,
"name" : " Other coli-conc application "
}
] يجب أن يكون عنوان URL متاحًا لأن الواجهة سترتبط به. ترتبط الجلسة بتطبيق إذا كان عنوان URL الخاص به يحتوي على url للتطبيق. سيتم فحص التطبيقات من الأعلى إلى الأسفل ، لذلك يجب عليك طلبها من معظم عناوين URL محددة إلى عنوان URL أقل محددًا (انظر المثال أعلاه).
npm run startيوفر الخادم واجهة ويب ، واجهة برمجة تطبيقات HTTP و WebSocket.
تتيح واجهة الويب للمستخدمين إنشاء وإدارة الحسابات مع اتصالات إلى هويات متعددة في مزودي الهوية (انظر مقدمي الخدمات). يتم استخدام مقدمي الخدمات لمصادقة المستخدمين لأن خادم تسجيل الدخول لا يقوم بتخزين أي كلمات مرور (تسجيل الدخول المفرد).
تسمح HTTP API و WebSocket لتطبيقات العميل بالتفاعل مع خادم تسجيل الدخول ، على سبيل المثال للتحقق مما إذا كان قد تم تسجيل الدخول إلى المستخدم ومعرفة الهويات التي تنتمي إلى مستخدم (انظر تسجيل الدخول-العميل-عميل تسجيل الدخول لمكتبات JavaScript لتوصيل تطبيقات الويب مع خادم تسجيل الدخول).
يمكن استخدام خادم تسجيل الدخول لمصادقة المستخدمين مقابل الخدمات الأخرى حتى يتمكن المستخدمون من إثبات هوياتهم.
يحتوي Directory bin على برنامج نصي مساعد لإدارة مثيل الخادم مثل إدراج حسابات المستخدمين وإدارة مقدمي الخدمات المحليين.
تستخدم الاختبارات نفس mongoDB كما تم تكوينها في .env ، فقط مع -test postfix بعد اسم قاعدة البيانات.
npm test يستخدم Login Server Passport (Github) كوسيطة مصادقة. يستخدم Passport ما يسمى "الاستراتيجيات" لدعم المصادقة مع مختلف مقدمي الخدمات. يمكن العثور على قائمة بالاستراتيجيات المتاحة هنا. الاستراتيجيات المدعومة حاليًا في خادم تسجيل الدخول هي:
نظرًا لأن الاستراتيجيات تستخدم معلمات مختلفة في عمليات الاسترجاع الخاصة بها ، فإن كل استراتيجية لها ملف التفاف الخاص به في strategies/ . لإضافة استراتيجية أخرى إلى خادم تسجيل الدخول ، أضف ملفًا يسمى {name}.js (حيث {name} هو اسم الاستراتيجية المستخدمة مع passport.authenticate ) مع الهيكل التالي (github كمثال):
/**
* OAuth Stategy for GitHub.
*/
// Import strategy here
import { Strategy } from "passport-github"
// Don't change this part!
export default ( options , provider , callback ) => new Strategy ( options ,
// Strategies have different callback parameters.
// `req` is always the first and the `done` callback is always last.
( req , token , tokenSecret , profile , done ) => {
// Prepare a standardized object for the user profile,
// usually using information from the `profile` parameter
let providerProfile = {
// Required, don't change this!
provider : provider . id ,
// Required: Choose a field that represents a unique user ID for this user
id : profile . id ,
// Optional: Provides a display name (e.g. full name)
name : profile . displayName ,
// Optional: Provides a username
username : profile . username
}
// Call a custom callback. `req`, `providerProfile`, and `done` are required,
// `token` and `tokenSecret` can be null.
callback ( req , token , tokenSecret , providerProfile , done )
} )يمكنك إلقاء نظرة على الاستراتيجيات الحالية كأمثلة وإضافة طلبك عبر طلب سحب.
بعد إضافة الإستراتيجية ، يمكنك استخدامها عن طريق إضافة مزود إلى providers.json :
[
{
"id" : " github " ,
"strategy" : " github " ,
"name" : " GitHub " ,
"template" : " https://github.com/{username} " ,
"options" : {
"clientID" : " abcdef1234567890 " ,
"clientSecret" : " abcdef1234567890abcdef1234567890 "
},
"image" : " https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg " ,
"url" : " https://github.com "
}
]يمكن أن يكون لكل كائن في قائمة مقدمي الخدمات الخصائص التالية:
id (مطلوب) - معرف فريد للمزود.strategy (المطلوبة) - اسم استراتيجية جواز السفر المستخدم من قبل المزود.name (مطلوب) - اسم عرض الموفر.template (اختياري) - سلسلة قالب لإنشاء URI (يمكن أن يكون العنصر النائب {field} أي حقل موفر في كائن providerProfile ، عادةً {id} أو {username} ).credentialsNecessary (اختياري) - تعيين إلى true إذا كانت بيانات اعتماد اسم المستخدم وكلمة المرور ضرورية لهذا المزود. بدلاً من إعادة التوجيه (لـ OAUTH) ، سيعرض خادم تسجيل الدخول نموذج تسجيل الدخول الذي سيرسل بيانات الاعتماد إلى نقطة نهاية.options (المطلوبة في الغالب) - كائن خيارات للاستراتيجية ، وغالبًا ما يحتوي على بيانات اعتماد العميل لنقطة نهاية المصادقة.image (اختياري) - صورة مرتبطة بالمزود. سيتم عرضه على صفحة تسجيل الدخول وفي قائمة الهويات المتصلة. يمكنك توفير صور ثابتة في المجلد static/ . ستكون قيمة الخاصية static/myimage.svg . إذا كان اسم الملف يطابق id المزود ، فسيتم ربط الصورة تلقائيًا.url (اختياري) - عنوان URL للموفر ؛ سيتم ربطها على صورتها /أيقونة تحت /account . هناك عناوين URL الافتراضية للاستراتيجيات github و orcid و mediawiki و stackexchange . فيما يلي مثال providers.json json يوضح كيفية تكوين كل من مقدمي الخدمات الحاليين:
[
{
"id" : " github " ,
"strategy" : " github " ,
"name" : " GitHub " ,
"template" : " https://github.com/{username} " ,
"options" : {
"clientID" : " abcdef1234567890 " ,
"clientSecret" : " abcdef1234567890abcdef1234567890 "
}
},
{
"id" : " orcid " ,
"strategy" : " orcid " ,
"name" : " ORCID " ,
"template" : " https://orcid.org/{id} " ,
"options" : {
"clientID" : " APP-abcdef1234567890 " ,
"clientSecret" : " abcdef1-23456-7890ab-cdef12-34567890 "
}
},
{
"id" : " mediawiki " ,
"strategy" : " mediawiki " ,
"name" : " Mediawiki " ,
"template" : " https://www.mediawiki.org/wiki/User:{username} " ,
"options" : {
"consumerKey" : " abcdef1234567890 " ,
"consumerSecret" : " abcdef1234567890abcdef1234567890 "
}
},
{
"id" : " stackexchange " ,
"strategy" : " stackexchange " ,
"name" : " Stack Exchange " ,
"template" : " https://stackexchange.com/users/{id} " ,
"options" : {
"clientID" : " 12345 " ,
"clientSecret" : " abcdef1234567890(( " ,
"stackAppsKey" : " 1234567890abcdefg(( "
}
},
{
"id" : " my-ldap " ,
"strategy" : " ldapauth " ,
"name" : " My LDAP " ,
"credentialsNecessary" : true ,
"options" : {
"server" : {
"url" : " ldap://ldap.example.com " ,
"bindDN" : " uid=admin,dc=example,dc=com " ,
"bindCredentials" : " abcdef1234567890 " ,
"searchBase" : " dc=example,dc=com " ,
"searchFilter" : " (uid={{username}}) "
}
}
},
{
"id" : " easydb " ,
"name" : " easydb test provider " ,
"strategy" : " easydb " ,
"credentialsNecessary" : true ,
"options" : {
"url" : " https://easydb5-test.example.com/api/v1/ "
}
},
{
"id" : " some-script " ,
"strategy" : " script " ,
"name" : " Some Script " ,
"credentialsNecessary" : true ,
"template" : " https://example.org/some-script/{id} " ,
"options" : {
"script" : " ./bin/example-script "
}
},
{
"id" : " cbs " ,
"strategy" : " cbs " ,
"name" : " CBS " ,
"credentialsNecessary" : true ,
"template" : " cbs:{id} " ,
"options" : {
"url" : " https://example.com/ext/api/colirich/users/info " ,
"apiKey" : " abcdef1234567890 "
}
}
] لتكوين مقدمي الخدمات المحليين ، يرجى استخدام البرنامج النصي المقدم ضمن bin/manage-local.js . سيسمح لك بإنشاء/حذف مقدمي الخدمات المحليين ، وإنشاء/حذف المستخدمين لمقدمي الخدمات المحليين.
يمكنك ضبط المسار إلى ملف providers.json PROVIDERS_PATH .env
ملاحظات حول استخدام مزود MediaWiki:
"baseURL": "https://www.wikidata.org/" .https://coli-conc.gbv.de/login/login/wikidata/return لمثيل تسجيل الدخول الخاص بنا).ملاحظات حول استخدام مزود البرنامج النصي:
lib/script-strategy.js ).options.script ) إما نسبة إلى مجلد جذر خادم تسجيل الدخول ، أو مسار مطلق (موصى به لـ Docker).bin/example-script .chmod +x ).id عندما كانت المصادقة ناجحة. اختياريا ، يمكن توفير name وسيتم استخدامه كاسم العرض.يوفر Login Server الرموز المميزة لـ JSON التي يمكن استخدامها للمصادقة مقابل الخدمات الأخرى (مثل JSKOS-Server). يستخدم Jsonweboken للتوقيع على الرموز.
بشكل افتراضي ، يتم إنشاء Keypair RSA جديد عند بدء التطبيق لأول مرة (2048 بت ، باستخدام Node-RSA). سيكون هذا keypair الذي تم إنشاؤه بشكل افتراضي متاحًا في ./private.key و ./public.key . يمكنك إعطاء ملف ./public.key إلى أي خدمة أخرى تحتاج إلى التحقق من الرموز. بدلاً من ذلك ، يتم تقديم المفتاح العام المستخدم حاليًا في /حول نقطة النهاية.
يمكنك أيضًا توفير مسار مخصص لملفات المفاتيح عن طريق تعيين JWT_PRIVATE_KEY_PATH و JWT_PUBLIC_KEY_PATH في .env . إذا كان لا يمكن العثور على أحد المفاتيح أو كليهما ، فسيتم إنشاء المفاتيح. بشكل افتراضي ، يتم استخدام خوارزمية RS256 ، ولكن يمكن استخدام أي خوارزمية مفتاح عامة أخرى عن طريق تعيين JWT_ALGORITHM .
بشكل افتراضي ، كل رمز صالح لمدة 120 ثانية. يمكنك ضبط هذا عن طريق تعيين JWT_EXPIRES_IN في .env .
يتم استلام الرموز إما من خلال نقطة النهاية /الرمز المميز أو باستخدام طلب WebSocket من Type token . بالإضافة إلى ذلك ، يتم إرسال الرمز المميز عبر WebSocket بعد تسجيل الدخول إلى المستخدم ثم بانتظام قبل انتهاء المميز الأخير.
مثال على كيفية التحقق من الرمز المميز:
import jwt from "jsonwebtoken"
// token, e.g. from user request
let token = "..."
// get public key from file or endpoint
let publicKey = "..."
jwt . verify ( token , publicKey , ( error , decoded ) => {
if ( error ) {
// handle error
// ...
} else {
let { user , iat , exp } = decoded
// user is the user object
// iat is the issued timestamp
// exp is the expiration timestamp
// ...
}
} )بدلاً من ذلك ، يمكنك استخدام Passport-JWT (على سبيل المثال سوف يتبع).
يعرض صفحة مقصودة مع معلومات عامة حول خادم تسجيل الدخول.
يعرض موقع لإدارة حساب المستخدم (إذا تم مصادقته بالفعل) أو يعيد التوجيه إلى /login (إن لم يكن مصادق عليه).
يعرض موقعًا لإدارة جلسات المستخدم (إذا تم مصادقة) أو إعادة توجيه إلى /login (إن لم يكن مصادقة).
يعرض موقع لتسجيل الدخول (إن لم يكن مصادقة) أو يوجه إلى /account (إذا تم المصادقة).
إذا تم إعطاء معلمة الاستعلام redirect_uri ، فسيتم إعادة توجيه الموقع إلى URI المحدد بعد تسجيل دخول ناجح. (إذا تم إعطاء المعلمة ، لكنها فارغة ، فستستخدم المرجع باعتباره URI.)
يعرض صفحة تسجيل الدخول لمزود. بالنسبة لمقدمي خدمات OAUTH ، ستعيد هذه الصفحة توجيهًا إلى صفحة الموفر لتوصيل هويتك ، والتي تقوم بعد ذلك بإعادة توجيه /login/:provider/return . بالنسبة لمقدمي الخدمات الذين يستخدمون بيانات الاعتماد ، سيظهر هذا نموذج تسجيل الدخول.
تتولى هذه الصفحة أيضًا redirect_uri (انظر /login أعلاه).
نقطة النهاية لمقدمي الخدمات باستخدام بيانات الاعتماد. إذا نجحت ، فسيتم إعادة توجيه /account ، وإلا فإنه سيتم إعادة توجيه مرة أخرى إلى /login/:provider .
يفصل مقدم الخدمة عن المستخدم ويعيد التوجيه إلى /account .
يسجل المستخدم من حسابه. لاحظ أن الجلسة ستبقى لأنها تستخدم في WebSockets. يمكّن ذلك التطبيق من إرسال الأحداث إلى WebSockets النشطة للجلسة الحالية ، حتى لو كان المستخدم قد سجل الخروج.
يعرض موقع لحذف حساب المستخدم.
يرتكب حذف حساب المستخدم وإعادة توجيه إلى /login .
يوفر الخادم نقطة نهاية إعادة توجيه OAUTH (إعادة توجيه URI) لكل مزود OAUTH.
نقطة نهاية رد الاتصال لطلبات OAuth. سيحفظ الهوية المتصلة للمستخدم (أو إنشاء مستخدم جديد إذا لزم الأمر) وإعادة توجيه /account .
قبل البرمجة مباشرة مقابل HTTP API و WebSocket API ، إلقاء نظرة على مكتبة Browser JavaScript Login-Client. يمكن رؤيته في العمل هنا (مصدر لهذا الموقع).
إرجاع كائن title مفاتيح (عنوان مثيل خادم تسجيل الدخول) ، env (البيئة ، مثل development أو production ) ، publicKey (عادةً ما يكون مفتاح RSA العمومي) ، algorithm (خوارزمية Jsonweboken المستخدمة). يتم استخدام المفتاح الخاص المقابل للمفتاح العام المحدد عند توقيع JWTs.
إرجاع قائمة من مقدمي الخدمات المتاحين (تم تجريدهم من المعلومات الحساسة).
إرجاع المستخدم الذي تم تسجيله حاليًا. إرجاع خطأ 404 عند عدم تسجيل أي مستخدم.
إرجاع مستخدم معين. يقتصر حاليًا على معرف المستخدم الخاص.
ضبط مستخدم معين. لا يمكن استخدامه إلا إذا تم تسجيل الدخول نفسه حاليًا. المسموح بها بالتغيير: name (سيتم تجاهل كل شيء آخر).
يزيل جميع الجلسات للمستخدم الحالي ، باستثناء الجلسة الحالية.
يزيل الجلسة باستخدام SessionId :id (يجب أن تكون جلسة للمستخدم الحالي).
إرجاع رمز الويب JSON بالتنسيق:
{
"token" : " <JWT> " ,
"expiresIn" : 120
}انظر أيضا: JWTS.
سيحتوي الرمز نفسه على خاصية user (والتي تحتوي إما على معلومات حول المستخدم المسجل حاليًا ، أو يكون فارغًا إذا لم يتم تسجيل المستخدم) وخاصية sessionID مطلوبة للمصادقة داخل اتصال WebSocket.
API WebSocket في URL / يرسل أحداث حول المستخدم أو الجلسة الحالية. يتم إرسال الأحداث كسلاسل مشفرة JSON التي تبدو هكذا:
{
"type" : " event name (see below) " ,
"date" : " date (as ISOString) " ,
"data" : {
"user" : {
"uri" : " URI of user " ,
"name" : " name of user " ,
"identities" : {
"xzy" : {
"id" : " ID of user for provider xzy " ,
"uri" : " URI or profile URL of user for provider xzy " ,
"name" : " display name of user for provider xzy (if available) " ,
"username" : " username of user for provider xzy (if available) "
}
}
}
}
}open - تم إرساله بعد إنشاء WebSocket ، استخدم هذا بدلاً من ws.onopen !loggedIn - يتم إرسالها عند تسجيل الدخول إلى المستخدم (سيتم إرساله فورًا بعد إنشاء WebSocket إذا تم تسجيل الدخول بالفعل)loggedOut - يتم إرسالها عند تسجيل المستخدم (سيتم إرساله مباشرة بعد إنشاء WebSocket إذا لم يتم تسجيل الدخول إلى المستخدم)updated - تم إرساله عند تحديث المستخدم (على سبيل المثال ، أضاف هوية جديدة ، إلخ)providers - تم إرسالها بعد إنشاء WebSocket Connection (يتكون من data.providers خاصية.about - تم إرسالها بعد إنشاء اتصال WebSocket (سيكون data الخصائص نفس التنسيق كما في GET /ABON)token - يتم إرساله عند تسجيل الدخول إلى المستخدم ثم على فترات زمنية قبل انتهاء صلاحية الرمز المميز السابق (سيكون data الخصائص نفس التنسيق كما في GET /Token)authenticated - تم إرسالها كرد على النجاح عند طلب المصادقة (انظر أدناه)pong - تم إرساله كإجابة على طلب Type ping (يمكن استخدامه لتحديد ما إذا كان WebSocket قد أصبح قديمة)sessionAboutToExpire - تم إرسالها عندما تنتهي الجلسة المرتبطة بها حاليًاerror - تم إرساله كإجابة على رسالة مشوهة عبر WebSocket (يتكون من data.message خاصية. message مع رسالة خطأ)يمكنك أيضًا إرسال طلبات إلى WebSocket. يجب أن تكون هذه سلاسل مشفرة JSON بالشكل التالي:
{
"type" : " name of request "
} هذا طلب خاص يستخدم JWT تم الحصول عليه من GET /TOKEN لربط WebSocket الحالي بجلسة معينة (تم إرسال token خاصية كائنات طلب الطلب)
يكون طلب authenticate ضروريًا في بعض الأحيان عند استخدام WebSocket من مجال مختلف عن خادم تسجيل الدخول. في هذه الحالة ، يجب طلب الرمز المميز عبر واجهة برمجة التطبيقات (على سبيل المثال ، باستخدام جلب مع credentials: "include" أو axios مع خيار withCredentials: true ) ويتم إرسالها عبر WebSocket. يتضمن الرمز المميز SessionId المشفر الذي سيتم ربطه بعد ذلك بتوصيل WebSocket. فيما يلي مثال على كيفية ظهور سير العمل من تطبيق الويب: https://coli-conc.gbv.de/login/api
فيما يلي مثال بسيط على كيفية الاتصال بـ WebSocket.
// Assumes server is run on localhost:3005
let socket = new WebSocket ( "ws://localhost:3005" )
socket . addEventListener ( "message" , ( message ) => {
try {
let event = JSON . parse ( message )
alert ( event . event , event . user && event . user . uri )
} catch ( error ) {
console . warn ( "Error parsing WebSocket message" , message )
}
} ) قبلت PRS.
dev كأساس. سيتم دمج التغييرات من dev في master فقط للإصدارات الجديدة.للمباريات فقط
يرجى العمل على فرع dev أثناء التطوير (أو الأفضل من ذلك ، التطوير في فرع الميزات ودمج في dev عندما يكون جاهزًا).
عندما يكون الإصدار الجديد جاهزًا (أي يتم الانتهاء من الميزات ، ودمجها في dev ، ونجاح جميع الاختبارات) ، قم بتشغيل البرنامج النصي للإصدار المضمون (استبدل "التصحيح" بـ "Minor" أو "Major" إذا لزم الأمر):
npm run release:patchهذا سوف:
devdevnpm version patch (أو "Minor"/"Major")devmasterdevmaster مع العلاماتdevبعد تشغيل هذا ، ستقوم إجراءات GitHub تلقائيًا بإنشاء مشروع إصدار GitHub جديد. يرجى تحرير ونشر الإصدار يدويًا.
MIT © 2019 Verbundzentrale des GBV (VZG)